备份 / 还原:方案与设计
本文讲设计成什么样、为什么这样设计;"踩过什么坑、怎么发现的"见
report-backup-restore.md,逐日证据见devlog-*。 卡片数据模型见architecture.md§8。
1. 目标与不变量
目标:把一张 TPM 虚拟智能卡完整地搬到同一台机器的重装后系统上,做到 「读卡器出现、卡片可识别、证书可读、私钥可用」。
硬前提(不支持的情形):还原只适用于同一颗 TPM —— 换机器、换 TPM、清除 TPM 都不行; 并且卡片不能被删除(tpmvscmgr destroy 会一并清除该卡在 TPM 内的持久密钥,删卡之后 即使有备份也无法还原)。还原完成后可能需要重启设备(驱动)或重启系统,卡片才会出现在读卡器 列表中(见还原流程 R7/R8)。
不变量(实现与评审都必须守住)
| # | 不变量 | 理由 |
|---|---|---|
| I1 | 备份必须覆盖卡片的五个部分(设备节点、store、TPM 密钥索引、设备属性、DPAPI 明文),少一项就是"看起来成功但不可用" | 见 architecture.md §8 的依赖链 |
| I2 | 解密只发生在源机、加密只发生在目标机;备份包里存的是源机解出的明文,还原时用目标机 DPAPI 重新封装 | 源机主密钥在目标机上不存在;搬主密钥会连带解密同账户的其它受保护数据,泄露面不可接受 |
| I3 | 任何"读不到"都必须与"确实没有"区分开并如实上报,禁止静默降级 | 失败伪装成空会让人在错误方向上排查(历史教训见 §11 ADR-4) |
| I4 | 还原是幂等的:重复执行不破坏已有数据;遇到本机已有且一致的内容跳过,不一致的卡片身份以备份为准 | 现场经常"重试一次" |
| I5 | 任何一步失败都要留下可判定的结论与证据(结论 + 日志 + 诊断) | GUI 下无法看命令行输出 |
2. 数据模型
2.1 备份包(.tsb,zip)
| 路径 | 内容 | 缺失后果 |
|---|---|---|
files/<store GUID>/… | 卡片数据目录(DPAPI 密文原样保留一份) | 卡片无数据 |
dpapi/<store GUID>/… | 上述 5 个 DPAPI 文件的明文(源机解密结果) | 目标机建不出密钥容器 → 证书不显示 |
pcp/<i>/… | TPM 密钥索引 *.PCPKEY(3 个候选目录) | 驱动找不到卡上密钥 |
registry/*.reg | 设备类实例与设备节点注册表导出 | 设备节点无法重建 |
tpmvsc-backup.json | 清单(见 §2.2) | 无法还原 |
store-index.txt | 目录清单,仅供诊断 | 无 |
2.2 清单字段
| 字段 | 含义 |
|---|---|
format | 包格式版本,当前 1(兼容策略见 §8) |
computer / tool / tool_version | 来源机器与工具信息 |
store | {guid, path}:卡片数据目录 |
files[] | 卡片数据文件:{path, size, sha256} |
pcp[] | 密钥索引目录:{index, dir, files} |
registry[] | 注册表导出:{key, file} |
device_properties[] | 设备属性全量:{fmtid, pid, type, data(base64)}(含 ACL 保护的 Properties) |
system_root | 源机 %SystemRoot%,用于改写属性里的绝对路径 |
dpapi[] | {path, file, account, flags, master_key, size} —— 还原时按 account + flags 重新加密 |
tpm | TPM 型号/厂商与 EK 指纹(用于判断是否同一颗 TPM) |
notes[] | 面向人的说明(含"包内含明文密钥材料"的安全提示) |
3. 备份流程
| 步骤 | 动作 | 失败处理 |
|---|---|---|
| B1 | 找卡:按名称/实例定位设备节点与其 store | 找不到即失败并提示 |
| B2 | 注册表:导出设备类实例与设备节点键 | 一项都没导出即失败 |
| B3 | store 文件:递归收集(含 sha256) | 单文件读不到 → 失败(不允许少文件);目录遍历失败 → 失败 |
| B4 | TPM 密钥索引:3 个候选目录 | 单个目录失败 → 记日志继续;子目录读不到 → 记「少收 N 处」 |
| B5 | 设备属性:经 PnP 属性接口读全量(绕开 ACL) | 读失败 → 返回 None → 记明确告警并继续(包会缺卡片身份) |
| B6 | DPAPI:识别 store 内的 DPAPI blob → 在本机解开成明文,记录账户与作用域 | 单个文件失败 → 记日志并跳过该文件(仍按密文备份);全部失败 → 明确写"全部解密失败,换机后不可用" |
| B7 | TPM 指纹 | 读失败 → 记录为空并说明原因 |
| B8 | 打包 + 写清单 + 写 store-index.txt | 写失败即失败 |
B6 的账户判定:用 blob 里的
guidMasterKey反查它在源机…\Microsoft\Protect\<SID>[\User]\下属于哪个账户(DPAPI.master_key_owner()), 不靠猜;查不到时记为 SYSTEM(驱动即服务账户)。
4. 还原流程
顺序不能随意调换 —— 后面的步骤依赖前面的产物。
| 步骤 | 动作 | 依赖 | 失败/降级 |
|---|---|---|---|
| R1 | 读清单、校验包完整性(sha256) | — | 包损坏即失败 |
| R2 | 导入注册表(设备类 + 设备节点) | — | 导入失败即失败 |
| R3 | 重建 PnP 设备节点(SetupDi 注册 + 安装 tpmvsc.inf);重建前把没有设备节点的孤儿 Enum 项导出留底再删 | R2 | 失败即失败(否则后面全无意义) |
| R4 | 写回 store 文件;其中 DPAPI 文件先用本机 DPAPI 按备份记录的账户与作用域重新加密 | R3 | 重新加密失败 → 退回原始密文写回并记录 |
| R5 | 写回 TPM 密钥索引(同名文件不覆盖,避免破坏本机其它 TPM 密钥) | — | 单文件失败 → 记日志继续 |
| R6 | 写回设备属性:卡片身份({2BEED4A8-…}/{2065F23D-…})不一致则覆盖,其余只补缺失;属性里的绝对路径按 system_root 改写 | R3 | 失败 → 记日志,并在结论里提示"读卡器可能不会出现" |
| R7 | 重新枚举设备 + 刷新相关服务,让驱动重读属性 | R3、R6 | 重试 + pnputil 兜底 |
| R8 | 判定:设备节点状态 + 读卡器状态 + DPAPI 自检 → 给结论 | 全部 | — |
5. 状态判定(结论的依据)
| 判定 | 取值 | 取自 |
|---|---|---|
| 设备节点 | missing / started / present / problem / unknown | CM_Locate_DevNodeW + CM_Get_DevNode_Status |
| 读卡器 | card(读卡器+卡都在)/ reader_only / absent | PC/SC 枚举(必须在交互会话执行) |
| DPAPI | ok / fail / none / unknown | 还原后读回自检:把 store 里的 DPAPI 文件逐个试解 |
结论规则(关键):读卡器 = card 但 DPAPI = fail 时不得报成功 —— 这种情况证书与私钥都不会 显示,结论明确要求"用新版工具在源系统重新备份后还原"。
6. DPAPI 设计
6.1 为什么必须两端各做一次
卡片上 keymaps / pinmaps / pinpolicy / blobs\auth / blobs\key-80 是 DPAPI blob (用户作用域、描述 TPMVSC),其主密钥在源机该账户的 DPAPI 存储里。 换机后驱动解不开这些数据 → 建不出密钥容器 → certutil -scinfo 报「找不到储存的密钥集」→ 证书不显示。
6.2 令牌借用(工具是管理员进程,但要按 SYSTEM 身份做 DPAPI)
驱动以服务账户身份运行,因此加解密必须在同一账户身份下进行:
遍历进程(K32EnumProcesses 取真实 pid)
→ 优先 services.exe / winlogon.exe / lsass.exe / wininit.exe / svchost.exe
→ OpenProcessToken(TOKEN_QUERY|TOKEN_DUPLICATE) → 比对 TokenUser SID
→ DuplicateTokenEx(SecurityImpersonation, TokenImpersonation)
→ SetThreadToken → 复核:① 线程 SID == 目标账户 ② 做一次 DPAPI 往返探针
→ 全部通过才返回成功;否则换下一个候选设计要点:
- 不按 pid 顺序扫:低 pid 的系统进程是受保护进程(取令牌直接
ACCESS_DENIED), 而可用的服务进程 pid 往往大于 400 —— 早期"扫 pid 1..399"的实现因此一个都借不到。 - 借到后必须实测:SID 正确不代表这个令牌能做 DPAPI(内存态进程的令牌 SID 也是 SYSTEM, 但用它调 DPAPI 会
ACCESS_DENIED),所以加了protect→unprotect往返探针。 - 备用路径:
unprotect会按「blob 记录的 flags → 用户作用域 → 机器作用域」× 若干账户依次尝试。
6.3 加密与回验
protect() 写入描述 TPMVSC、按备份记录的 flags 选择用户/机器作用域,写完立刻解一次比对; 内容不一致或解不开就视为失败,由还原流程退回原始密文写回(宁可保留旧数据也不写坏卡)。
7. 错误处理与降级策略(一览)
| 场景 | 策略 | 依据 |
|---|---|---|
| store 单文件读不到 / 目录遍历失败 | 中止备份(宁可没有备份,也不能有"少文件的备份") | 备份的价值在于完整 |
| TPM 密钥索引目录不可读 | 记日志继续(辅助数据,缺了会在结论里体现) | 索引可能部分受保护 |
| 设备属性读取失败 | 记明确告警继续,包内 device_properties 为空 | 其它部分仍值得备份 |
| DPAPI 单个文件解不开 | 记日志跳过该文件;全部失败时明确写"换机后不可用" | 数据仍以密文保留 |
| 还原时 DPAPI 重新加密失败 | 退回原始密文写回 + 记日志 | 幂等、不破坏性 |
| 还原时写设备属性失败 | 记日志 + 结论提示"读卡器可能不会出现" | 让用户知道后果 |
| store 绑定读不到(拿不到 store GUID) | 退化为整目录备份并记日志 | 兜底可用(未实测) |
8. 兼容性与版本策略
| 情况 | 行为 |
|---|---|
旧包没有 dpapi[] | 按原样写回密文;结论里明确"卡上密钥在本机解不开,需重新备份"(不谎报成功) |
旧包没有 device_properties[] | 跳过属性写回并提示;读卡器可能不出现 |
旧包没有 pcp[] | 跳过密钥索引写回并提示 |
format 高于本工具支持 | 拒绝还原并提示升级工具 |
system_root 与目标机不同 | 属性里的绝对路径按目标机 %SystemRoot% 改写 |
| 目标机为另一颗 TPM | 不支持:还原要求同一颗 TPM。卡片与证书可以重建,但卡上私钥无法恢复(父密钥在源机 TPM 内),需重新签发证书;工具在 TPM 指纹不一致时会明确提示 |
9. 可观测性
- 日志文件:
%APPDATA%\TPMSmartCard\logs\<backup|restore>-<时间戳>.log。 - 界面上展示三段:结论(一句话可执行)+ 逐条日志(过程)+ 现场诊断 (设备节点状态、读卡器、设备属性项数、TPM 密钥索引目录、DPAPI 状态、store 绑定)。
- 日志原则:成功的重复细节收敛成一行(例如 DPAPI 解密只留汇总),失败保留完整原因 (候选账户、作用域、错误码),细节可从清单的
dpapi记录反查。
10. 扩展点与未决
| 方向 | 说明 |
|---|---|
| 整包加密 | 目前 .tsb 含密钥材料明文;可用 PIN / 管理口令派生密钥对整包加密(需设计口令找回策略) |
| 跨 TPM 私钥恢复 | 需要研究 TPM 持久对象的迁移语义,当前不承诺 |
| 增量 / 多卡备份 | 目前一次一张卡;批量与增量未设计 |
| 整目录降级路径 | "读不到 store 绑定 → 整目录备份"未实测,且可能打包同机其它 store,需要收紧 |
11. 设计决策记录(ADR)
| # | 决策 | 理由 | 被否的替代方案 |
|---|---|---|---|
| ADR-1 | 备份时在源机解密收明文,还原时在目标机重新加密 | 目标机没有源机主密钥;实现只在本地调 DPAPI,无需额外服务 | ① 搬 DPAPI 主密钥文件(跨 OS 安装通常不可移植,且会连带暴露同账户其它数据)② 要求用户手工导出/导入主密钥 |
| ADR-2 | 用运行期借用令牌做 DPAPI,而不是安装服务或计划任务 | 工具本身已是管理员进程,借一次令牌即可,零安装、零残留 | 临时 SYSTEM 计划任务(需管理员、留残留、LocalService 任务实测不可用) |
| ADR-3 | 用 SetupDi 注册设备节点,而不是只 reg import | 实测只写注册表 PnP 不会建出 ROOT 设备;重枚举也不会 | reg import + 重启(实测无效) |
| ADR-4 | 读取类接口区分"失败"与"空"(返回 None vs []),遍历失败直接中止 | 失败伪装成空会把排查引到错误方向(历史事故:假警告"厂商卡片身份 0 项"、os.walk 静默丢文件) | 统一返回空值(简单但危险) |
| ADR-5 | 卡片身份属性不一致则覆盖,其余属性只补缺失 | 本机残留的旧身份会让驱动按另一张卡找密钥(现场"两台实例一模一样"就是这么来的) | 一律跳过已存在属性(曾导致身份无法纠正) |
| ADR-6 | TPM 密钥索引同名不覆盖 | 目标机可能已有同名索引(如 Windows AIK),覆盖会破坏本机其它 TPM 密钥 | 一律覆盖(风险不可控) |
| ADR-7 | 还原只支持同一颗 TPM;TPM 指纹不一致时给出明确提示,暂不强制阻断 | 「同一颗 TPM」是卡上私钥可用的硬前提(父密钥在 TPM 内部);但阻断会连带挡掉"先重建卡片与证书、再重新签发证书"这类仍然可用的流程 | 指纹不一致直接拒绝还原 |
| ADR-8 | 日志成功收敛、失败详尽 | 现场反馈"日志太长,成功时的解密细节没必要逐条显示" | 全量输出(可读性差) |