摘要与签名者绑定,构建来源则保留为独立证据。

镜像签名把摘要绑定到受信任签名者

容器镜像标签是可变指针,摘要则标识内容。有效签名把一份关于该摘要的声明绑定到签名密钥;这把密钥是否代表获授权的人或工作负载,要由信任策略判断。单靠签名,不能证明谁构建了镜像、构建是否遵循所宣称的流程,也不能证明软件内容安全。这些问题需要不同证据,不能因为校验成功就合并成一个结论。 真正有用的边界,是部署之前的验证决定。校验方需要检查内容绑定和预期信任根,再执行身份与来源策略。registry 地址、熟悉的标签,或者存在某份签名,都不足以独立构成放行依据。必须知道自己信任谁、允许其声明什么,以及这份声明究竟覆盖哪一个产物。 摘要是身份,标签是指针 OCI 清单摘要对精确清单字节进行哈希,清单中的描述符再通过各自摘要标识配置与层,客户端也须核对这些绑定。在哈希抗碰撞假设下,清单改变会得到不同摘要。如果只修改存储层中的字节却不更新描述符,应触发内容完整性失败,而不是悄悄把原镜像重新定义成另一份内容。内容寻址依赖实际校验,不只是路径看起来像摘要。 Cosign 的普通镜像签名载荷包含镜像摘要,验证时会把它与请求的镜像比较。不同标签可以指向同一个已签名摘要;标签一旦移动,先前的校验不能覆盖新对象。应解析并验证目标摘要,再部署同一个摘要。如果校验之后重新按标签拉取,就留下检查与使用之间的竞争窗口;重复查询标签也不能彻底关闭这个窗口。 标签字符串不能绑定镜像内容 对字面字符串 app:v2 签名,认证的是这个声明,并不是名字后面不断变化的对象。签名也可以包含其他上下文,所以需要检查真实载荷格式与校验器行为,而不是把所有方案都说成只签一个裸哈希。不要为了让不匹配的载荷通过,就关闭摘要声明校验,否则签名有效与目标镜像之间的关键联系会丢失。 基于密钥的校验,需要由策略说明为什么信任所给公钥。无密钥校验还要检查预期证书身份与 OIDC 颁发者,以及证书链和所要求的透明日志证据。Fulcio 证书把身份关联到密钥,不会检查构建过程或认证软件质量。要判断构建声明,还需要适当的来源证明、受信任的构建者,以及证明中主体摘要与待运行产物一致的检查。 校验才是控制,签名不是 强制检查可以放在准入或部署前,但效果取决于实际覆盖范围。需要确认哪些工作负载与 registry 被纳入策略,以及校验失败时如何处理。只记录摘要不能证明发布获授权;即使签名正确、身份也获授权,仍不能据此排除所有漏洞或恶意行为。安全测试与构建来源验证应各自提供证据,不能相互替代。 密码学签名有效与当前策略接受,是两回事。退役密钥需要更新所有相关验证方的信任策略,仅生成一把新密钥不会自动完成撤销。短期证书配合可信签名时间证据,可以支持证书到期之后验证历史签名。透明日志让活动可检查,却不会自动撤销泄露身份或阻挡恶意发布。事故响应仍须决定保留哪些身份、时间窗口、摘要和证明,不能把日志存在等同于风险已解决。 坏掉的样子 部署步骤先验证一个摘要,随后又按可变标签拉取;另一次推送就可能让最终运行的字节变掉。需要把经过验证的摘要一直传到部署交接处,并明确它指向单平台清单还是多平台索引,确认运行时怎样选择具体平台镜像。这样才能说明实际运行对象与被检查对象之间的关系。 另一种失败是接受任何能通过密码学检查的签名者。应把信任限制到获授权密钥,或明确的身份与颁发者组合,并测试错误签名者和未批准摘要会被拒绝。发现泄露后需要调查并改变策略;这并不自动证明该密钥以前签过的所有产物都恶意,也不能承诺继续接受它们一定安全。 镜像签名信任:机制与证据边界。 快速验证 下面是命令模板,不是可直接运行的夹具:registry 是示例,摘要也被省略。进行获授权的 registry 检查之前,需提供已有测试镜像的完整摘要、可信公钥,并确认所安装的 Cosign 与 Skopeo 版本。本次源码审阅没有签名、操作 registry 或执行部署,因此没有新的集成运行结果。 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 tree 发现关联对象,列出对象不等于验证其中声明。Skopeo 可以查看标签当下指向;如果不同,只说明它与刚才检查的摘要不同,另一个摘要仍可能有自己的签名。即使两者相同,也只是观察时刻的快照,无法阻止标签随后移动。 ...

十月 21, 2027 · 1 分钟 · XB Field Notes
历史双目录示例中,只做编译器路径映射会使目标文件一致,但最终可执行文件仍不同。目标文件相同不能证明发布二进制相同。

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

可复现构建的含义是:另一方依据声明的源码、构建步骤和相关环境,能够逐字节重建指定产物。这个承诺必须说明比较哪些输出;如果交付的可执行文件不同,只比较目标文件相同是不够的。复现结果为源码与分发内容之间的联系提供证据,而源码审阅、来源证明和签名分别回答其他问题。两次相同的恶意构建仍然有害,被污染的工具链也可能稳定复现自己的异常行为。 图示为文章记录的 Apple 工具链比较,包含仅做编译器处理时最终可执行文件仍不一致的情况。平台专用重跑仍受阻,本次绘图也未重跑构建。本地一致不等于独立复现,也不证明源码安全。 记录真正影响字节的输入 源码版本与编译器版本只是输入的一部分。依赖、目标架构、SDK、链接器、参数、文件遍历顺序、目录路径、时间戳、区域设置和打包工具,都可能进入最终结果。应固定实际相关的输入,并说明比较边界。容器镜像有助于描述环境,却不会自动消除网络下载、宿主机挂载或变化的时间戳。一个镜像标签也不必然代表永远不变的镜像内容。 时间宏和调试信息是常见差异来源。SOURCE_DATE_EPOCH 为支持该约定的工具提供稳定时间,并不会改写所有程序对当前时间的理解。编译器的前缀映射能够规范化嵌入的源文件路径;链接器还可能独立记录目标文件位置或其他元数据。因此,编译阶段消除了差异,仍应继续检查实际链接输出,不能在中间产物相同时就停止验证。 坏掉的样子 只比较挑选出的某个段,可能掩盖真正发布文件中的不同。应明确可复现性覆盖可执行文件、调试符号、压缩归档还是带签名安装包。如果签名过程加入会变化的数据,就说明并论证可复现负载的边界,同时单独校验签名后的分发包。还应记录 SDK 和链接器的构建标识;仅有编译器版本,无法重建另外两者的输入。不能悄悄删除难以处理的字节,再宣称原始文件完全相等;比较范围本身也是需要审阅的契约。 同一台机器上的两次摘要一致,是这些输入条件下本地重复构建的证据,不等于多个独立构建方已经验证,也不能证明源码与工具正确。反过来,摘要不一致首先意味着需要调查,不能立即断定发生了攻击。应定位差异来自哪个组件,再判断是不是有输入没有纳入环境声明。没有记录 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