Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第七章 · 授权体系:口令、PCR Policy 与 PolicyAuthorize

本章目标

  • 搞清楚实验 B 里 -p pcr:sha256:16 这一行参数背后到底发生了什么;
  • 理解 TPM 授权(Authorization)的三种方式,重点吃透策略会话(policy session)的哈希链机制;
  • 亲手跑通 PolicyAuthorize 全流程:封存、解封、模拟升级、重签、再解封,一次成功一次失败都要看到;
  • 顺带把 seal.pub / seal.priv / seal.ctx 这三件套彻底讲明白。

从一个遗留问题开始

回忆实验 B,我们解封时用的是:

tpm2_unseal -c seal.ctx -p pcr:sha256:16

当时我说“-p 指定授权方式“,然后就过去了。现在把这个问题捡起来:这个 -p pcr:sha256:16 到底让 TPM 做了什么?

直觉的回答是“TPM 检查了一下 PCR 16 对不对“。但这个说法太模糊了——怎么检查?和谁比?比对了之后这个结论怎么传递到 unseal 这个动作上?要知道 TPM 的每条命令是独立的,命令之间没有“我刚才检查过了“这种记忆。

答案是:这行参数让工具替你悄悄开了一个会话(session),在会话里执行了一条策略命令,然后带着这个会话去执行 unseal。这一整套机制就是本章的主角。理解了它,第六章 luksDump 输出里那个看不懂的 tpm2-pubkey 字段也就顺手解开了。

TPM 授权的三种方式

TPM 里每个对象(密钥、封存的秘密)都有一个授权字段,你想操作它,就得先通过授权。授权方式一共三种:

方式凭据是什么特点典型场景
口令授权(authValue)一串口令/HMAC简单直接,但每次用都要带口令普通密钥的访问控制
PCR policy一段“路径哈希“绑死环境状态,环境变就废封存秘密到启动状态
Authorize policy一段“路径哈希“绑一把签名密钥,可审批新状态需要在线升级的系统

后两种看起来不同,底层是同一个机制:策略会话(policy session)。区别只在于“路径“里放了哪些策略命令。我们一层层拆开看。

策略会话:又一条哈希链

前面章节我们见过 PCR 的 extend 哈希链:

PCR_new = Hash(PCR_old || 新度量值)

策略会话里的 policyDigest 走的是一模一样的结构:

policyDigest_new = Hash(policyDigest_old || 策略命令名 || 命令参数)

整个机制的运作分三步:

  1. 创建对象时:算出你期望的“走完所有策略命令后的最终 policyDigest“,把它写进对象的 authPolicy 字段。这相当于在对象上钉了一句话:“想动我,必须走一遍我规定的路径,走完的哈希得等于这个值。”
  2. 使用对象时:开一个策略会话,逐条执行策略命令。每执行一条,TPM 就把这条命令按上面的公式 extend 进会话的 policyDigest。
  3. 执行目标命令时(比如 unseal):TPM 拿会话当前的 policyDigest 和对象的 authPolicy 比对,相等才放行,不等直接拒绝。

思考:为什么要把授权设计成哈希链,而不是简单的“检查条件列表“?因为链式结构让顺序也成为断言的一部分。“先满足条件 A 再满足条件 B“和“先 B 再 A“算出来的摘要不同。而且每条策略命令一旦 extend 进去就抹不掉,会话没有“撤销“操作——和 PCR 不能随意重置是同一个设计哲学。

实验 B 的 -p pcr:sha256:16 就是这套机制的快捷方式:工具替你做了一串隐式操作——开策略会话、执行 TPM2_PolicyPCR、带着会话执行 unseal、结束后销毁会话。你在命令行上没看见,但 TPM 那边一步没少。

PCR policy:最常见的一块砖

策略命令有很多种,每条都是路径上的一块砖。最常用的是 TPM2_PolicyPCR

sudo tpm2_createpolicy --policy-pcr -l sha256:16 -L policy.bin

这条命令生成的策略只含一条指令。它的语义值得逐字读:TPM 现场读取 PCR 16 的当前值,把这个值的哈希 extend 进 policyDigest 链。也就是说,这条命令钉下的断言是——“执行到这一步时,PCR 16 必须等于我此刻看到的样子”。

后面的推导就是纯数学了:

  • 创建对象时算 authPolicy 那一刻,PCR 16 的值被固化进了最终摘要;
  • 将来解封时,会话里再执行 PolicyPCR,TPM 又读一次 PCR 16;
  • 如果这期间 PCR 16 被 extend 过,读出来的值不同 → 链上 extend 的内容不同 → 最终 policyDigest 对不上 authPolicy → 拒绝。

实验 B 里我们模拟篡改后解封,看到的报错是:

ERROR: Esys_Unseal(0x99D) - tpm:session(1):policy check failed

现在你知道 0x99D policy check failed 的完整含义了:不是“PCR 不对“这种模糊判断,而是“你的会话走出的哈希,和对象里钉死的哈希,不相等“。

策略命令是可以组合的,几个常见的:

  • PolicyPCR + PolicyAuthValue:既要环境状态对,又要输口令。双因素,防“环境没被改但人不是主人“的场景;
  • PolicyOR:多分支,满足任意一条即可。比如“PCR 是 A 状态或者是 B 状态都行“——听起来很美好,但维护 N 个合法状态就要列 N 个分支,状态多了会失控;
  • PolicyCounterTimer / PolicyNv 等:基于计数器、NVRAM 的条件,本书不展开。

纯 PCR policy 的死穴

实验 D 我们把 LUKS 密钥绑到了 PCR 上,开机自动解锁,很爽。但这里埋着一个迟早要爆的雷:

PCR 值是被写死(更准确说,是被哈希固化)进对象的 authPolicy 里的。 这意味着环境有任何合法变化——内核升级、固件更新、bootloader 换版本——PCR 值就会变,旧对象直接解不开。

补救办法只有一个:先用旧环境解封出秘密,再按新环境的预期 PCR 值重新封存一遍。第六章的实验 D 我们确实就是这么干的(升级前重算 PCR 预期值、重新 enroll)。

但这个流程有个让人不舒服的时序问题:你得先发现解不开,才去补救。万一是远程服务器、自动升级、半夜重启呢?机器卡在 LUKS 密码提示界面等你,你人在睡觉。而且“解封→重封存“这个操作要求秘密的持有方在场,流程上很被动。

:这不是 TPM 的缺陷,这是“把授权绑在具体值上“这种设计的固有代价。TPM 官方早就给出了标准解法,就是下面要讲的 Authorize policy。

Authorize policy:加一层间接

TPM2_PolicyAuthorize 的思路用一句话概括:对象不再绑定具体的 PCR 值,改为绑定“一把签名密钥“。

它的语义是:

我不在乎 PCR 现在是什么值。只要你能拿出一张“票据“,证明审批方(持有指定私钥的人)签名认可过“你这条会话当前走出的策略摘要“,我就认。

拆开看执行流程。会话执行 PolicyAuthorize 时,TPM 做两件事:

  1. 验证票据(ticket):票据是 TPM 自己早前通过 TPM2_VerifySignature 验签后签发的,内容是“公钥为 N 的密钥,确实签名过摘要 D“;
  2. 比对:票据里的 D 必须等于当前会话实际走出的 policyDigest。对得上,就把会话的 policyDigest 重置为一个由审批方公钥 name 决定的新值;对不上,当场拒绝。

妙处在于第 2 步之后的部分:无论被审批的具体策略摘要是什么,只要审批方是同一把公钥,PolicyAuthorize 执行完后会话的 policyDigest 都落在同一个值上。而对象的 authPolicy 里写的就是这个值。于是:

  • 环境状态 A:审批方签了 A 的策略摘要 → 放行;
  • 升级到状态 B:审批方签一张 B 的 → 也放行;
  • 封存的对象从头到尾不用动

授权的判断从“环境是不是我当初封存时的样子“变成了“环境是不是审批方认可的样子“。秘密的持有方和升级的审批方可以是两个人,审批方甚至从头到尾接触不到秘密本身。

纸上谈完了,下面真刀真枪跑一遍。

实验:PolicyAuthorize 全流程

老环境:Omarchy 虚拟机 + swtpm。建个目录开工:

mkdir -p ~/tpm-lab/authz && cd ~/tpm-lab/authz

第 0 步:准备环境状态 A

我们把 PCR 16 当作“环境状态“的载体。先清零,得到一个确定的起点:

sudo tpm2_pcrreset 16

此刻 PCR 16 全零,这就是“状态 A“。

第 1 步:生成审批方密钥

审批方的角色在现实中是 OEM 厂商或系统管理员。用 OpenSSL 生成一把 RSA 密钥:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out signer.key
openssl rsa -in signer.key -pubout -out signer.pub

注意:现实里这把私钥应该离线保管(比如放在不联网的机器或 HSM 里),升级审批时拿出来签一下名就走。实验里图方便放在同目录,别学这一点。

第 2 步:把公钥导入 TPM,拿到 name

PolicyAuthorize 认的不是公钥文件,而是 TPM 给这把公钥起的 name——可以理解成公钥的哈希,是 TPM 眼中这把钥匙的身份证号:

sudo tpm2_loadexternal -C o -G rsa -u signer.pub -c signer.ctx -n signer.name

-n signer.name 让工具把 name 存盘,后面每条 PolicyAuthorize 命令都要用它。

第 3 步:计算状态 A 的策略摘要并签名

先算出“状态 A 下纯 PCR policy“的最终摘要:

sudo tpm2_createpolicy --policy-pcr -l sha256:16 -L policyA.bin

policyA.bin 里就是那个 32 字节的 policyDigest——“PCR 16 等于全零“这句断言的哈希形式。审批方拿私钥签它:

openssl dgst -sha256 -sign signer.key -out policyA.sig policyA.bin

第 4 步:TPM 验签,换票据

签名要在 TPM 这边验过、换成 TPM 自己签发的票据(ticket),PolicyAuthorize 才认:

sudo tpm2_verifysignature -c signer.ctx -g sha256 --scheme=rsassa \
    -m policyA.bin -s policyA.sig -t ticketA.bin

坑(这个坑我实打实踩过):tpm2-tools 5.8 里这条命令的参数行为变了。OpenSSL 产出的是裸签名(raw signature),必须显式加 --scheme=rsassa,工具才会按纯签名处理:

  • 很多人(包括我)凭老教程的记忆写 -f plain--format=plain——都会报错。--format 已被废弃,而且被映射到了 --scheme 上,于是你会看到莫名其妙的 Unknown signing scheme, got: plain
  • 如果什么都不加,工具默认按 TPMT_SIGNATURE 结构(TPM 原生签名格式)去解析 OpenSSL 的裸签名,解析失败,报一个反序列化错误(0x9000b),错误信息完全不提“你缺了个参数“这回事。

教训:tpm2-tools 版本之间参数差异不小,--help 比记忆可靠,比网上的老教程更可靠。

第 5 步:trial 会话预演,算出写进对象的 authPolicy

现在关键一步:我们要算的不是“状态 A 的策略摘要“,而是“走完 PolicyPCR 再走 PolicyAuthorize 之后“的最终摘要——这个值才写进对象的 authPolicy。用 trial 会话来算:trial 会话不做任何验证(不读 PCR、不验票据),只机械地算哈希,专门用来“预演“路径、得到最终摘要:

sudo tpm2_startauthsession --policy-session -S trial.ctx
sudo tpm2_policypcr -S trial.ctx -l sha256:16
sudo tpm2_policyauthorize -S trial.ctx -L policyAuthz.bin \
    -n signer.name -i policyA.bin -t ticketA.bin
sudo tpm2_flushcontext trial.ctx

-L policyAuthz.bin 把 trial 会话的最终 policyDigest 导出来。注意一个细节:trial 会话里的 PolicyPCR 并没有真去读 PCR(反正全零状态读不读都一样),PolicyAuthorize 也不验票——它们只是把自己的命令名和参数 extend 进链。所以这个最终值里只有审批方公钥的 name,没有任何具体 PCR 值

第 6 步:封存秘密

sudo tpm2_createprimary -C o -c prim.ctx
echo -n "机密:游戏存档密钥 42" | sudo tpm2_create -C prim.ctx \
    -L policyAuthz.bin -u sealA.pub -r sealA.priv -i-
sudo tpm2_load -C prim.ctx -u sealA.pub -r sealA.priv -c sealA.ctx

和实验 B 几乎一样,唯一区别是 -L 给的策略文件从“PCR policy“换成了“authorize policy“。这个对象现在绑的是“审批方的公钥“,而不是“PCR 16 = 全零“。

第 7 步:状态 A 下解封(成功)

这次不用快捷方式,显式开会话走完整流程,看看 -p pcr:... 背后的完整姿势:

sudo tpm2_startauthsession --policy-session -S s.ctx
sudo tpm2_policypcr -S s.ctx -l sha256:16
sudo tpm2_policyauthorize -S s.ctx -n signer.name \
    -i policyA.bin -t ticketA.bin
sudo tpm2_unseal -c sealA.ctx -p session:s.ctx
sudo tpm2_flushcontext s.ctx
机密:游戏存档密钥 42

成功了。三条会话命令各司其职:PolicyPCR 让 TPM 现场读 PCR 16 extend 进链(此刻全零,和签名时一致);PolicyAuthorize 验票 + 比对当前摘要 + 把摘要重置到公钥 name 决定的值;unseal 带着这个会话去碰对象,摘要匹配,放行。

第 8 步:模拟系统升级,用旧审批解封(失败,且失败点很有信息量)

extend 一下 PCR 16,环境进入状态 B:

echo "new kernel 6.17" | sudo tpm2_pcrextend 16:sha256=$(sha256sum | cut -d' ' -f1)

再用状态 A 的旧审批走一遍解封流程:

sudo tpm2_startauthsession --policy-session -S s.ctx
sudo tpm2_policypcr -S s.ctx -l sha256:16
sudo tpm2_policyauthorize -S s.ctx -n signer.name \
    -i policyA.bin -t ticketA.bin
ERROR: Esys_PolicyAuthorize(0x1C4) - tpm:parameter(1):value is out of range or is not correct

看清楚:报错的是 Esys_PolicyAuthorize根本轮不到 unseal。这个失败点精确验证了规格里的一个细节——PolicyAuthorize 执行时,TPM 强制比对“被签名的策略摘要(policyA.bin)“和“当前会话实际走出的摘要”。PCR 16 变了,会话在 PolicyPCR 那步 extend 进去的值就变了,两个摘要在 authorize 这步就对不上,当场拒绝。

思考:这说明审批方批准的不是一句抽象的“我信任这台机器“,而是具体到哈希的确定状态。签的是 policyA.bin,就只认 policyA.bin 对应的那条路径。授权没有半点含糊的空间。

清理会话现场:

sudo tpm2_flushcontext s.ctx

第 9 步:审批方为状态 B 重签,对象不动,解封复活

审批方算出状态 B 的策略摘要,重新签名、换票:

sudo tpm2_createpolicy --policy-pcr -l sha256:16 -L policyB.bin
openssl dgst -sha256 -sign signer.key -out policyB.sig policyB.bin
sudo tpm2_verifysignature -c signer.ctx -g sha256 --scheme=rsassa \
    -m policyB.bin -s policyB.sig -t ticketB.bin

注意:sealA.pub / sealA.priv 一个字节都没动。直接走解封流程,这次带上 B 的审批材料:

sudo tpm2_startauthsession --policy-session -S s.ctx
sudo tpm2_policypcr -S s.ctx -l sha256:16
sudo tpm2_policyauthorize -S s.ctx -n signer.name \
    -i policyB.bin -t ticketB.bin
sudo tpm2_unseal -c sealA.ctx -p session:s.ctx
sudo tpm2_flushcontext s.ctx
机密:游戏存档密钥 42

又成功了。环境已经面目全非,对象原封不动,仅凭一张新签名就恢复了授权。

这说明什么

把两种方案放在一起对比,差异一目了然:

纯 PCR policyAuthorize policy
环境变化后重新封存秘密(先解封再封)重新签名策略摘要,对象不动
谁能授权新状态必须能接触到秘密原文的人只持签名私钥的审批方
升级流程解封 → 换策略 → 重封存,秘密持有方在场离线算新 PCR 预期值 → 签名 → 下发
签名/封存时机只能在变化发生后补救可以预先签好,随升级包一起发布

最后一行是点睛之笔:审批方从头到尾接触不到秘密本身。签名是数学运算,签的是哈希不是秘密——这意味着密钥分发和版本审批可以离线做、委托给别人做。这就是它比纯 PCR policy 高出的那一层。

真实世界里的对应

这套机制不是实验室玩具,你身边大概率正在跑:

UKI 的 .pcrsig 段。 第六章说过 systemd-cryptenroll 默认绑 PCR 7,其实还可以绑 PCR 11(内核与 UKI 各段的度量)。问题来了:内核一升级 PCR 11 就变,难道每次升级都要人肉重封存?不用。UKI 镜像里有一个 .pcrsig 段,内核厂商构建镜像时就把“本 UKI 对应的 PCR 11 预期值“的策略摘要签好名打了进去。升级换内核,新镜像自带新签名,解锁零干预——这正是我们实验里“重签 ticketB“那一步的工业化版本。

systemd-cryptenroll --tpm2-public-key 给它一把公钥,它就用 PolicyAuthorize 把 LUKS 密钥绑到这把公钥上,而不是绑死当前 PCR 值。回头翻第六章 cryptsetup luksDump 的输出,TPM2 token 里那个当时没解释的 tpm2-pubkey 字段——现在你知道了,那就是审批方公钥,等价于我们实验里的 signer.pub

加餐:seal.pub / seal.priv / seal.ctx 三件套

从实验 B 到现在,目录里一直躺着这三个文件。大多数人的困惑是:“哪个是秘密?哪个能备份?ctx 到底是个啥?“用一个具体场景讲透。

假设你开发了一款单机游戏,存档用一个对称密钥 K 加密,防止玩家直接改存档文件。K 不能写死在程序二进制里——会被逆向出来。于是你把 K 封存进 TPM:只有“没被动过手脚的系统环境“才能取出 K。tpm2_create 跑完,你手上就有了三样东西:

seal.pub(TPM2B_PUBLIC)= 保险柜门上的规则铭牌。 里面没有任何秘密,全是元信息:对象属性(fixedTPM 表示永不可导出、fixedParent 表示不能换爹)、算法标识、还有 authPolicy 字段——就是“PCR 16 必须等于 X“这句断言的哈希。任何人拿到这个文件,只能知道规则长什么样,而且规则受哈希保护,改一个字节整个对象就废了。

seal.priv(TPM2B_PRIVATE)= 真正的信封。 K 被父密钥加密后的密文,外加完整性校验。父密钥从 TPM 内部种子派生,永不出芯片。所以这个文件你可以随便备份、被小偷偷走、甚至误传上云盘——离开那颗 TPM,它就是一堆乱码,暴力破解等于破解 TPM 的种子密钥体系。

seal.ctx = 取件凭条。 这是最容易误解的一个。它是运行时引用,不是数据tpm2_load 做的事是:把 pub + priv 送进 TPM,TPM 用父密钥拆开信封、校验完整性,把对象放进芯片里那块很小的易失内存,然后返回一个句柄(handle)。.ctx 文件存的就是这个句柄的序列化形式,给后续命令当“取件号“用。它本身毫无秘密;而且 TPM 易失内存很小、重启即清空,所以每次开机要用对象,都得重新 tpm2_load 拿一张新凭条。

注意:别把 .ctx 文件当回事去备份它。重启之后里面的句柄就是一张过期的取件号,窗口后面什么都没有。要备份的是 pub + priv 两件套。

整个生命周期串起来:

创建:tpm2_create ──→ seal.pub + seal.priv(落盘,可备份)
使用:tpm2_load(pub + priv) ──→ seal.ctx(内存句柄)──→ tpm2_unseal / tpm2_sign / ...

小结

  • TPM 授权有三条路:口令、PCR policy、Authorize policy,后两者共用策略会话机制;
  • 策略会话的核心是 policyDigest 哈希链,和 PCR extend 同构:“你必须走一遍我规定的路径,路径哈希对上才算数”;
  • 实验 B 的 -p pcr:sha256:16 只是隐式开会话执行 PolicyPCR 的快捷写法;
  • 纯 PCR policy 把环境值固化进对象,升级就得重新封存,被动且要求秘密持有方在场;
  • PolicyAuthorize 把对象绑到审批方公钥上,环境变化只需重签一张策略摘要,对象不动,审批可以离线、可以委托——实验中失败发生在 PolicyAuthorize 一步而非 unseal,说明审批针对的是具体哈希状态,没有含糊空间;
  • UKI 的 .pcrsigsystemd-cryptenroll --tpm2-public-key 是这套机制的工业实现;
  • seal.pub 是规则铭牌、seal.priv 是加密信封、seal.ctx 是重启即废的取件凭条,要备份的只有前两个。

授权讲透了,下一章换个视角看攻防:PCR 7 度量的到底够不够?攻击者抱着你的硬盘能玩出什么花样?