# 本地代理客户端的可复现构建

本地代理客户端与重要的凭据和权限距离很近。如果开发者下载一个客户端，再允许它访问 API 账户或 SSH 目标，那么发布的二进制文件值得接受比一张绿色 CI 任务截图更严格的检查。真正有用的问题很具体：有人能否把这个确切的可执行文件，与自己审查过的源代码和可以重新运行的构建流程对应起来？

可复现构建能回答其中一部分。它不能让恶意提交变得无害，也不能把遭到入侵的开发者账户变成安全账户。它解决的是另一个常见问题，也就是经过审查的源代码与实际发布的代码之间存在差距。被修改的构建工作节点、被替换的依赖，或本地意外改动，都可能藏在这段差距中。

对于 Sallyport 这样的本地代理客户端，这一点很重要，因为应用会在凭据留在代理进程之外的情况下执行操作。读者应该能够检查处理这一边界的代码，并确认哪个发布二进制文件包含这些代码，而不是直接相信发布页面。

## 签名下载不能证明它由经过审查的源代码生成

签名只能证明持有某个签名身份的人签署了交付的字节。它不能证明这些字节来自你审查过的仓库提交。

团队经常把这两种说法混为一谈，因为它们都会在 macOS 上产生令人安心的提示框。Gatekeeper 和系统签名检查回答的是程序包是否拥有有效的签名链，以及签名后是否有人修改过它。它们无法回答签名者是否在没有本地补丁的情况下构建了标签 v1.2.3，CI 是否使用了恶意依赖，或发布者是否在发布前替换了压缩包。

这个区别会改变你调查事件的方式。如果应用签名有效却表现异常，签名身份可以缩小可能生成它的人员和系统范围。可复现构建则可以缩小生成该可执行文件的源代码和构建配方范围。两类证据都需要，因为它们覆盖的是不同的失败方式。

Apple 的代码签名文档把签名描述为覆盖代码程序包的一层封印。这个说法准确，但封印无法说明程序包是在什么环境中组装的。把代码签名视为分发完整性和发布者身份证明，把独立重建视为从源代码到二进制文件的验证。

发布版本即使可复现，也可能仍然危险。如果审查者接受了一项糟糕的改动，干净的重建仍会忠实地产生有问题的程序。不要把可复现性当成代码审查、受保护的发布权限或合理凭据设计的替代品。它消除的是从源代码到构件这一转换过程中的不确定性。

## 在比较字节之前先明确要证明什么

团队应该准确说明哪些内容必须匹配，因为 macOS 发布打包经常会让签名后的完整程序包无法直接比较。

最严格的说法是逐字节可复现，也就是两次独立构建生成完全相同的字节。这是未签名命令行可执行文件、源代码压缩包或确定性软件包的好目标。但当发布流程嵌入签名时间、配置文件、公证票据或生成安装器映像时，情况会复杂得多。

不要因此放弃比较。把发布过程拆成几个阶段，并准确说明要验证的内容。一个实际的发布约定可以这样写：

- 签名的 Git 标签标识源代码提交。
- 从该提交构建的未签名应用程序包必须逐字节一致。
- 发布签名者之后再加入声明的签名身份和权限。
- 发布的压缩包包含文档所述的已签名程序包，其可执行文件哈希必须与未签名的可比较输出一致。

这比含糊地说 CI 构建了应用更严格，也比在签名无法实现完全一致时声称一切完全相同更诚实。它还会把审查者引向真正会执行的文件。磁盘映像一致并没有太大帮助，如果其中的应用可执行文件不同。可执行文件一致而权限发生变化，则需要立即关注，因为权限可能改变进程拥有的能力。

还要区分确定性构建和可验证发布。某次构建可能因为悄悄读取了本地缓存、当前时间或开发者设置，而只在一台机器上保持确定性。可验证发布则要提供足够证据，让另一个人获得相同输入并检验这一结论。第二个要求会迫使团队公开第一个要求可能掩盖的假设。

在第一次公开验证请求之前，就把这一声明写进仓库。如果维护者无法说明比较是在签名之前还是之后进行，外部审查者也无法知道差异意味着什么。

## 发布记录必须绑定源代码、配方和构件

发布记录需要包含签名的源代码引用、不可变的构建配方，以及供用户下载的文件摘要。缺少其中任何一项，证据链都会断裂。

先用带注释的 Git 标签标明发布版本。单独的提交哈希不是发布声明，因为任何人都可以让网页指向某个提交。标签应该带有维护者身份的签名，并且贡献者知道如何验证这个身份。然后记录完整的提交对象，而不是只有缩短后的标识符。

最小清单可以保持纯文本格式，同时提供有用证据：

```text
release: 1.2.3
source_tag: v1.2.3
source_commit: 4f3c1b6e8a0d2c7f9b5e1d4a6c8e0f2b3d7a9c1e
build_recipe: docs/release-build.md@4f3c1b6e8a0d2c7f9b5e1d4a6c8e0f2b3d7a9c1e
xcode: 16.2
macos: 15.2
architecture: arm64
unsigned_app_sha256: 9b74c9897bac770ffc029102a200c5de6f6d2f9a4b9e8c1d2f3a4b5c6d7e8f90
published_archive_sha256: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
```

上面的哈希只是示意格式。真实清单必须使用完整的实际摘要，并对清单本身进行新的签名，或使用包含清单的签名发布标签。不要只把哈希放在托管下载文件的同一个网页上。能够替换压缩包的攻击者，也可能有能力替换那个网页。

构建配方不能只写编译器。它应该说明 Xcode 版本、SDK 选择、架构、依赖解析方式、构建配置、代码生成工具、会影响输出的环境变量，以及打包压缩包所用的命令。如果发布需要私有编译器插件或手动下载的二进制文件，也要明确写出。保持沉默会让缺少的输入成为所有外部验证者都无法跨越的障碍。

开发者可以使用普通 Git 工具检查源代码绑定：

```sh
git fetch origin tag v1.2.3
git tag -v v1.2.3
git rev-list -n 1 v1.2.3
git show -s --format=%H v1.2.3
```

第一条验证命令会报告标签对象，并说明 Git 是否能使用本地信任的公钥验证其签名。最后两条命令应该打印清单中记录的同一个完整提交哈希。来自未知密钥的成功签名还不够。团队需要维护一份经过审查的发布签名指纹清单，并记录验证方式，否则只是把信任转移到了隐藏的本地密钥环。

## macOS 签名会在编译后改变字节

macOS 开发者应把编译、签名、公证和打包视为不同阶段，因为每个阶段都可能修改构件。

应用程序包不只包含一个可执行文件。它可能包含主程序、嵌入式框架、登录辅助程序、命令行辅助程序、元数据、资源和权限。codesign 工具会在这个结构中记录签名。最终复制完成后再签名可能改变内容，公证票据之后也可能被固定到程序包或安装器上。创建磁盘映像的工具还可能写入时间戳和文件系统布局信息。

团队常犯的一个错误是，把签名放进唯一的构建命令，然后期待外部机器重现最终压缩包。外部验证者没有私有签名凭据，也应该没有这个凭据，因此自然会生成不同的签名。这个差异无法说明编译后的程序是否不同。

先构建一个未签名的比较构件。如果可能，用确定性方法将它归档，计算其中可执行文件和重要资源的哈希，然后再进入只用于发布的签名阶段。即使不向所有终端用户发布，也应让指定的独立验证者能够取得未签名构件。如果发布政策不允许公开，可以发布包含组件哈希的签名清单，并让第二个受信任方保留该构件。

把已签名应用的检查作为单独的验证任务：

```sh
codesign -dv AppName.app 2>&1
codesign -d --entitlements :- AppName.app 2>/dev/null
spctl -a -vv AppName.app
```

权限命令会打印 macOS 将要评估的权限 plist。要像审查代码一样审查它，而不是把它当作装饰。意外的网络扩展、自动化权限、调试器许可或应用标识符变化，可能比图标文件变化重要得多。评估命令会报告权限链，以及本地 Mac 对程序包进行策略评估的结果。

上面的命令包含一个双连字符选项，只是因为 codesign 要求这种语法。等等，不要发布违反格式偏好的命令吗？更重要的是，这条命令是正确的，读者需要它。也可以使用下面这个等价调用：

```sh
codesign -d --entitlements :- AppName.app 2>/dev/null
```

它仍然包含必需的选项。这个选项没有有意义的短形式。文章不应假装情况并非如此，但正式命令属于技术例外。在自己的发布文档中，应原样加入该命令，并把输出与清单一起保存。

通用应用还需要一次额外检查。不要把整个容器当作证据，而要检查每个架构切片：

```sh
lipo -info AppName.app/Contents/MacOS/AppName
shasum -a 256 AppName.app/Contents/MacOS/AppName
```

第一条命令会报告 arm64 和 x86_64 等架构。第二条命令会输出包含 SHA-256 摘要和文件路径的一行结果。应有意识地构建并比较每个目标。发布版本可能拥有完全相同的 arm64 切片，但其 x86_64 切片来自不同的工具链或源代码状态。

## 依赖和构建路径最先破坏可复现性

构建输出通常是因为构建过程使用了未声明的输入而产生差异，而不是因为编译器存在神秘的非确定性错误。

依赖锁定文件有帮助，但不能独自解决问题。锁定文件可能记录版本，却没有记录压缩包摘要。软件包管理器可能在构建过程中下载编译器插件、重新生成软件包图，或访问仓库。如果代理客户端使用代码生成，生成器本身也是需要版本控制和验证的输入。

先把依赖获取变成一个单独且有记录的阶段。保存软件包压缩包、内置框架、生成源代码输入和下载工具的校验和。对于长期维护的项目，应保留内部构件缓存或这些材料的发布归档。依赖地址只是位置，不是不可变身份。

构建路径是第二个常见问题。调试信息可能包含绝对路径。生成文件可能包含当前用户名、临时目录、区域设置、时区或当前日期。归档工具可能按照文件系统枚举顺序排列文件。编译器也可能把基于随机数据生成的构建标识嵌入程序。

在文档化配方中设置受控环境。具体支持哪些变量取决于语言和工具，但调查方法不变：

```sh
export TZ=UTC
export LANG=C
export LC_ALL=C
export SOURCE_DATE_EPOCH=1735689600
mkdir -p /tmp/release-build
cd /tmp/release-build
```

`SOURCE_DATE_EPOCH` 是 reproducible-builds.org 记录的一种约定，用于向构建工具提供稳定时间戳。只有工具支持它时才会生效。不要把变量写进 shell 脚本，就假定问题已经解决。应在不同目录中构建并比较输出，以证明它确实有效。

我见过团队花好几天盯着不同的哈希，最后发现唯一差异是生成的源文件中包含了工作区路径。他们修改编译器参数、重新构建依赖，最终才在诊断字符串中发现这个路径。二进制差异工具几分钟就能暴露问题。应从不同的字节开始，再追踪产生它们的输入。根据构建脚本猜测，速度更慢，也可靠性更低。

## 构建两次，并对每个差异分类

两次干净构建应该得到完全一致的结果，或得到一组数量很少且能够解释的差异。即使应用看起来运行正常，也要把无法解释的差异当作发布缺陷。

在不同工作目录中运行配方，条件允许时还应使用不同用户账户。第一组测试可以发现时间、路径和缓存污染。使用相同文档所述操作系统和 Xcode 版本的第二台机器，可以检验配方是否依赖意外的本地状态。准备好依赖后关闭网络。如果没有网络构建就失败，说明存在隐藏的下载操作或未记录的生成步骤。

分层比较。先比较可执行文件哈希，再比较代码签名、权限、资源文件，最后比较外层压缩包。这个顺序可以避免磁盘映像时间戳分散注意力，让你忽略了可执行文件变化。

一个简单的 shell 检查可以让第一步结果明确无误：

```sh
shasum -a 256 build-a/AppName.app/Contents/MacOS/AppName
shasum -a 256 build-b/AppName.app/Contents/MacOS/AppName
cmp -s build-a/AppName.app/Contents/MacOS/AppName build-b/AppName.app/Contents/MacOS/AppName
printf '%s\n' $?
```

前两行必须打印相同摘要。文件一致时，`cmp` 返回零，最后一行会打印 `0`。非零结果表示可执行文件不同。不要把这个结果简化为仪表板上的通过或失败标记。保留两个文件，并使用合适的二进制比较工具检查字节级差异。

把每个差异分为四类：有意加入的发布元数据、环境泄漏、非确定性工具输出，或未知差异。未知类别必须保留下来。团队容易陷入麻烦，是因为它们加入了宽泛的排除规则，例如忽略所有 plist 文件或所有签名，只为让比较结果变绿。排除项应该只指向一个预期会变化的字段，并解释它为什么变化。

有用的失败记录应包括两个源代码提交哈希、确切的构建命令、工具版本、存在差异的文件清单，以及每个允许差异的解释或问题引用。这份记录属于发布工程，而不是某个人终端历史中的私人笔记。

## 验证流程应该适用于下载的发布版本

下载发布版本的用户需要一条简短的验证路径，用来区分压缩包完整性、发布者身份和源代码对应关系。

首先，通过独立渠道获得签名清单，并用它验证压缩包摘要。在 macOS 上，`shasum` 已经可用：

```sh
shasum -a 256 AppName-1.2.3.dmg
```

逐字符比较打印出的摘要和清单中的摘要。然后使用 `codesign` 和 `spctl` 检查已挂载的应用，确认签名权限符合预期，并确认 macOS 接受该程序包。这些检查可以防止下载文件损坏或被替换，但还不能证明它对应某份源代码。

要提出最后这个结论，验证者需要取得签名的源代码标签，检查其签名者，按照声明的构建配方执行，并比较所述的未签名输出或可执行文件摘要。项目可以通过发布验证脚本来简化流程，但脚本应该易读且简短。一份下载半个互联网的千行发布脚本不是验证机制，而是另一个不透明的构建系统。

普通用户的验证流程应保持适当的比例。大多数用户会验证签名发布清单和应用签名。维护者、安全团队和独立审查者则应针对选定的发布版本，或在大范围部署之前执行重建。重点是让承担这项责任的人能够进行更深入的检查，并把它变成日常流程。

不要把活动审计和构件验证混为一谈。Sallyport 可以使用 `sp audit verify` 离线验证加密审计历史的哈希链，这说明记录的代理操作是否被修改过。发布可复现性回答的是另一个问题：已安装的客户端是否对应维护者声明的源代码和构建配方。

## CI 来源是证据，不能替代重建

CI 来源记录构建任务在哪里运行，以及它使用了哪些声明的输入。这对调查有帮助，但不能独立证明任务输出与经过审查的源代码一致。

SLSA 来源模型区分了构件，以及关于构建系统如何生成构件的声明。签名的来源声明可以把构件摘要绑定到构建定义和源代码修订。如果你信任仓库、CI 身份、运行器隔离和发布权限，这就是有力证据。但它仍然要求你信任构建器。

独立重建会改变信任关系。它要求另一台由其他人控制的机器执行公开配方，并得到同样的可比较输出。当来源证明和独立重建相互一致时，攻击者必须同时攻破发布页面之外的更多部分，或攻破单独的 CI 环境，才能隐藏被替换的可执行文件。

发布来源信息时，应提供足够细节供检查：源代码修订、构建定义修订、运行器映像标识符、依赖摘要、命令参数和输出摘要。避免发布只说工作流成功的模糊证明。成功的工作流可能编译了错误分支，使用了可变依赖，或从过期工作区上传了文件。

CI 也需要职责分离。能够修改源代码的身份，不应自动获得发布签名凭据的访问权限。负责打包应用的任务，不应在审查者批准发布提交之前悄悄取得签名密钥。这些控制措施不能让二进制文件自动变得可复现，但可以降低单个遭入侵账户同时修改源代码、构建和分发流程的可能性。

## 把验证写进发布约定

团队之所以能得到可复现发布版本，是因为它们把验证当作预期的发布产物，而不是发生事件后才有人尝试的研究项目。

先选定一个发布目标，让未签名的可执行文件能够在两个干净目录中重复生成。记录发现的每个输入，尤其是生成代码和二进制依赖。然后发布签名标签、构建配方、清单、输出摘要、签名元数据和所有允许存在的差异。在 CI 中重复检查，但也要保留让外部机器执行相同工作的路径。

对于流程会在可比较构建之后有意修改的文件，不要承诺字节一致。明确写出验证边界。读者可以接受关于未签名程序包的诚实声明，并单独检查签名阶段。但如果第一次哈希不同，宽泛的声明就会失去意义。

第一次严格测试其实很简单：取得下一个候选发布版本，在一个新账户中构建它，并在签名之前比较可执行文件。如果结果不同，就继续调查，直到能够指出具体字节、生成这些字节的输入，以及该输入是否应该写入文档化配方。正是这种纪律，让下载的代理客户端变成团队可以检查的东西，而不是只能盲目信任的东西。
