第七章 · 授权体系:口令、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 || 策略命令名 || 命令参数)
整个机制的运作分三步:
- 创建对象时:算出你期望的“走完所有策略命令后的最终 policyDigest“,把它写进对象的
authPolicy字段。这相当于在对象上钉了一句话:“想动我,必须走一遍我规定的路径,走完的哈希得等于这个值。” - 使用对象时:开一个策略会话,逐条执行策略命令。每执行一条,TPM 就把这条命令按上面的公式 extend 进会话的 policyDigest。
- 执行目标命令时(比如 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 做两件事:
- 验证票据(ticket):票据是 TPM 自己早前通过
TPM2_VerifySignature验签后签发的,内容是“公钥为 N 的密钥,确实签名过摘要 D“; - 比对:票据里的 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 policy | Authorize 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 的
.pcrsig和systemd-cryptenroll --tpm2-public-key是这套机制的工业实现; seal.pub是规则铭牌、seal.priv是加密信封、seal.ctx是重启即废的取件凭条,要备份的只有前两个。
授权讲透了,下一章换个视角看攻防:PCR 7 度量的到底够不够?攻击者抱着你的硬盘能玩出什么花样?