附着在容器镜像上的摘要与加密签名。

签名镜像可以证来源

容器镜像的引用是一个可变指针,摘要则不可变。签名把摘要和一把密钥绑在一起,于是校验方能在运行之前先问:这些字节出自谁手。这正是 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 报出另一个摘要——那意味着标签现在指向从未被签名的字节。 ...

十月 21, 2027 · 1 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现构建差异的定位。

可复现构建把源码与产物对应起来

可复现构建的含义是:另一方依据声明的源码、构建步骤和相关环境,能够逐字节重建指定产物。这个承诺必须说明比较哪些输出;如果交付的可执行文件不同,只比较目标文件相同是不够的。复现结果为源码与分发内容之间的联系提供证据,而源码审阅、来源证明和签名分别回答其他问题。两次相同的恶意构建仍然有害,被污染的工具链也可能稳定复现自己的异常行为。 记录真正影响字节的输入 源码版本与编译器版本只是输入的一部分。依赖、目标架构、SDK、链接器、参数、文件遍历顺序、目录路径、时间戳、区域设置和打包工具,都可能进入最终结果。应固定实际相关的输入,并说明比较边界。容器镜像有助于描述环境,却不会自动消除网络下载、宿主机挂载或变化的时间戳。一个镜像标签也不必然代表永远不变的镜像内容。 时间宏和调试信息是常见差异来源。SOURCE_DATE_EPOCH 为支持该约定的工具提供稳定时间,并不会改写所有程序对当前时间的理解。编译器的前缀映射能够规范化嵌入的源文件路径;链接器还可能独立记录目标文件位置或其他元数据。因此,编译阶段消除了差异,仍应继续检查实际链接输出,不能在中间产物相同时就停止验证。 坏掉的样子 只比较挑选出的某个段,可能掩盖真正发布文件中的不同。应明确可复现性覆盖可执行文件、调试符号、压缩归档还是带签名安装包。如果签名过程加入会变化的数据,就说明并论证可复现负载的边界,同时单独校验签名后的分发包。不能悄悄删除难以处理的字节,再宣称原始文件完全相等;比较范围本身也是需要审阅的契约。 同一台机器上的两次摘要一致,是这些输入条件下本地重复构建的证据,不等于多个独立构建方已经验证,也不能证明源码与工具正确。反过来,摘要不一致首先意味着需要调查,不能立即断定发生了攻击。应定位差异来自哪个组件,再判断是不是有输入没有纳入环境声明。没有记录 SDK、链接参数或打包规则,可能就足以让相同源码生成不同文件。 快速验证 下面实验在 arm64 macOS、Apple clang 21.0.0 及其本机链接器上执行过。保存为 repro-build.py,安装命令行开发工具后运行 python3 repro-build.py。链接参数属于 Apple 工具链,不应假设 Linux 链接器也接受。脚本创建两个临时构建目录,用三种处理方式编译同一小段 C 程序;目录名差异是刻意保留的输入扰动。 from hashlib import sha256 from pathlib import Path from tempfile import TemporaryDirectory import json import os import subprocess source = '#include <stdio.h>\nint main(void) { puts(__FILE__); puts(__DATE__ " " __TIME__); }\n' with TemporaryDirectory() as directory: base = Path(directory) hashes = {mode: {'objects': [], 'executables': []} for mode in ('unmapped', 'compiler-only', 'complete')} output = [] for name in ('one', 'two'): root = base / name root.mkdir() src = root / 'main.c' src.write_text(source) env = {'PATH': os.environ['PATH'], 'LC_ALL': 'C', 'SOURCE_DATE_EPOCH': '1700000000'} for mode in hashes: obj = root / (mode + '.o') binary = root / mode flags = [] if mode == 'unmapped' else [f'-ffile-prefix-map={root}=/src'] subprocess.run(['clang', '-g', '-O0', *flags, '-c', str(src), '-o', str(obj)], cwd=root, env=env, check=True, capture_output=True) os.utime(obj, (1700000000, 1700000000)) linker = ['-Wl,-reproducible'] if mode == 'complete': linker.append(f'-Wl,-oso_prefix,{root}/') subprocess.run(['clang', *linker, str(obj), '-o', str(binary)], cwd=root, env=env, check=True, capture_output=True) hashes[mode]['objects'].append(sha256(obj.read_bytes()).hexdigest()) hashes[mode]['executables'].append(sha256(binary.read_bytes()).hexdigest()) if mode == 'complete': output.append(subprocess.check_output([str(binary)], text=True).splitlines()) equality = {mode: {kind: pair[0] == pair[1] for kind, pair in records.items()} for mode, records in hashes.items()} assert equality == {'unmapped': {'objects': False, 'executables': False}, 'compiler-only': {'objects': True, 'executables': False}, 'complete': {'objects': True, 'executables': True}} assert output == [['/src/main.c', 'Nov 14 2023 22:13:20']] * 2 print(json.dumps({'sha256': hashes, 'equal_between_directories': equality, 'complete_program_output': output, 'same_host_only': True})) 所有处理都使用时间值 1700000000,并固定目标文件的修改时间。没有路径映射时,目标文件和可执行文件都不同。只做编译器映射后,目标文件摘要已经相等,最终可执行文件仍然不同:链接器调试映射中还保留着各自的目标文件路径。这个中间失败说明,编译阶段通过的检查不能直接为实际发布的二进制文件背书。 ...

八月 15, 2027 · 2 分钟 · XB Field Notes