签名镜像可以证来源
容器镜像的引用是一个可变指针,摘要则不可变。签名把摘要和一把密钥绑在一起,于是校验方能在运行之前先问:这些字节出自谁手。这正是 registry 与清单层回答不了的问题——它们只能告诉你字节从哪里来,不能告诉你谁做的。 张力在于:签名只有在被校验时才是一项控制,而校验只有在信任根被管理好时才有意义。签名只是流水线里的几行;决定哪些密钥算数、一把密钥退役之后怎么办,才是真正的工程。 摘要是身份,标签是指针 摘要是对精确清单字节的哈希,镜像一变摘要就变。对摘要签名,就是把签名绑在内容上而不是名字上。用 EC P-256 密钥对、真实的 SHA-256 摘要和一份 cosign 形状的载荷,openssl dgst -verify 返回成功;把产物翻转一个字节,摘要随之改变,同一次校验返回失败。 标签的行为不同。三个标签指向同一个已签名摘要,全都能通过校验,因为标签从来不在被签名的载荷里。这正是设计:标签是指向摘要的指针,改指向是常规的 registry 操作。把标签挪到一个从未被签名的摘要上,对解析后摘要的校验就会失败——但前提是校验方先去解析标签,而不是直接相信你手上那个可变的引用。 对标签签名绑不住任何东西 如果签的是字符串 app:v2 而不是摘要,校验就会永远成功,因为这个字符串永远不变。把那个标签指向一个完全不同的镜像,签名照样通过:它对一个字符串说了真话,而这个字符串对字节什么都没说。所以签名必须签在摘要上,校验必须先解析标签再比对。 信任根是另一半。签名只证明「持有私钥的人产出了它」;公钥,或者一张由 Fulcio 签发、并配有一条 Rekor 日志条目的证书,才把它变成「这个身份构建了它」。分发和轮换这个锚点就是安全边界,而它完全在签名之外。 校验才是控制,签名不是 流水线签完名就直接部署、不做校验,买到的只是装饰。实测对比很直白:被篡改的产物在部署步骤做校验时被挡住;同一条产物在部署步骤只记录来源信息时顺利上线——而那份来源记录从此写着被篡改的摘要。强制点应该在准入,或者部署前的一道闸门,让未签名的镜像根本到不了节点。准入控制器在这里的价值,是它不依赖流水线的自觉:策略在对象进入集群的那一刻求值,跳过签名不是「忘了做」,而是直接失败。 撤销是签名内部完全没有答案的那部分。签名没有有效期、没有吊销列表:在它的密钥下永远有效。轮换密钥并不能撤销任何东西——旧签名在新密钥下被拒,在旧密钥下依旧被接受——所以任何持有已退役密钥的人都能继续产出可通过校验的产物。解法是配短时效证书和透明日志的信任根,再加上钉住身份的策略,而不只是检查「存在某个签名」。 坏掉的样子 最经典的是策略引擎从未检查过的未签名镜像。流水线签名,部署步骤按标签拉取并运行,而这个缺口在有人往同一标签推入不同字节之前一直不可见。审计记录从此为一份没人校验过的镜像声称来源,这比完全没有来源记录更糟。 第二种是签名能通过、但签名所用的密钥你已经不再信任。因为签名内部没有撤销机制,泄露或已退役的密钥会继续签出可接受的产物,而爆炸半径是那把密钥签过的每一个产物,且是回溯性的。在策略里钉住签名身份,让证书保持短时效,并把签名记进透明日志,这样一把意料之外的密钥会以一条日志条目出现,而不是一次无声通过。 快速验证 校验摘要,再确认标签仍然指向它: cosign verify --key cosign.pub registry.example.com/app@sha256:9ab5a134... cosign tree registry.example.com/app@sha256:9ab5a134... skopeo inspect --format '{{.Digest}}' docker://registry.example.com/app:latest cosign verify 必须打印出被校验的身份并以 0 退出,cosign tree 应列出挂在该摘要上的签名与证明。skopeo inspect 必须打印出你刚校验过的同一个摘要。错误的结果是:verify 报找不到匹配的签名,或者 verify 成功而 skopeo 报出另一个摘要——那意味着标签现在指向从未被签名的字节。 ...