本地代理客户端的可复现构建
可复现构建把本地代理客户端二进制文件与经过审查的源代码、记录完备的 macOS 构建配方、签名标签和可验证构件连接起来。

本地代理客户端与重要的凭据和权限距离很近。如果开发者下载一个客户端,再允许它访问 API 账户或 SSH 目标,那么发布的二进制文件值得接受比一张绿色 CI 任务截图更严格的检查。真正有用的问题很具体:有人能否把这个确切的可执行文件,与自己审查过的源代码和可以重新运行的构建流程对应起来?
可复现构建能回答其中一部分。它不能让恶意提交变得无害,也不能把遭到入侵的开发者账户变成安全账户。它解决的是另一个常见问题,也就是经过审查的源代码与实际发布的代码之间存在差距。被修改的构建工作节点、被替换的依赖,或本地意外改动,都可能藏在这段差距中。
对于 Sallyport 这样的本地代理客户端,这一点很重要,因为应用会在凭据留在代理进程之外的情况下执行操作。读者应该能够检查处理这一边界的代码,并确认哪个发布二进制文件包含这些代码,而不是直接相信发布页面。
签名下载不能证明它由经过审查的源代码生成
签名只能证明持有某个签名身份的人签署了交付的字节。它不能证明这些字节来自你审查过的仓库提交。
团队经常把这两种说法混为一谈,因为它们都会在 macOS 上产生令人安心的提示框。Gatekeeper 和系统签名检查回答的是程序包是否拥有有效的签名链,以及签名后是否有人修改过它。它们无法回答签名者是否在没有本地补丁的情况下构建了标签 v1.2.3,CI 是否使用了恶意依赖,或发布者是否在发布前替换了压缩包。
这个区别会改变你调查事件的方式。如果应用签名有效却表现异常,签名身份可以缩小可能生成它的人员和系统范围。可复现构建则可以缩小生成该可执行文件的源代码和构建配方范围。两类证据都需要,因为它们覆盖的是不同的失败方式。
Apple 的代码签名文档把签名描述为覆盖代码程序包的一层封印。这个说法准确,但封印无法说明程序包是在什么环境中组装的。把代码签名视为分发完整性和发布者身份证明,把独立重建视为从源代码到二进制文件的验证。
发布版本即使可复现,也可能仍然危险。如果审查者接受了一项糟糕的改动,干净的重建仍会忠实地产生有问题的程序。不要把可复现性当成代码审查、受保护的发布权限或合理凭据设计的替代品。它消除的是从源代码到构件这一转换过程中的不确定性。
在比较字节之前先明确要证明什么
团队应该准确说明哪些内容必须匹配,因为 macOS 发布打包经常会让签名后的完整程序包无法直接比较。
最严格的说法是逐字节可复现,也就是两次独立构建生成完全相同的字节。这是未签名命令行可执行文件、源代码压缩包或确定性软件包的好目标。但当发布流程嵌入签名时间、配置文件、公证票据或生成安装器映像时,情况会复杂得多。
不要因此放弃比较。把发布过程拆成几个阶段,并准确说明要验证的内容。一个实际的发布约定可以这样写:
- 签名的 Git 标签标识源代码提交。
- 从该提交构建的未签名应用程序包必须逐字节一致。
- 发布签名者之后再加入声明的签名身份和权限。
- 发布的压缩包包含文档所述的已签名程序包,其可执行文件哈希必须与未签名的可比较输出一致。
这比含糊地说 CI 构建了应用更严格,也比在签名无法实现完全一致时声称一切完全相同更诚实。它还会把审查者引向真正会执行的文件。磁盘映像一致并没有太大帮助,如果其中的应用可执行文件不同。可执行文件一致而权限发生变化,则需要立即关注,因为权限可能改变进程拥有的能力。
还要区分确定性构建和可验证发布。某次构建可能因为悄悄读取了本地缓存、当前时间或开发者设置,而只在一台机器上保持确定性。可验证发布则要提供足够证据,让另一个人获得相同输入并检验这一结论。第二个要求会迫使团队公开第一个要求可能掩盖的假设。
在第一次公开验证请求之前,就把这一声明写进仓库。如果维护者无法说明比较是在签名之前还是之后进行,外部审查者也无法知道差异意味着什么。
发布记录必须绑定源代码、配方和构件
发布记录需要包含签名的源代码引用、不可变的构建配方,以及供用户下载的文件摘要。缺少其中任何一项,证据链都会断裂。
先用带注释的 Git 标签标明发布版本。单独的提交哈希不是发布声明,因为任何人都可以让网页指向某个提交。标签应该带有维护者身份的签名,并且贡献者知道如何验证这个身份。然后记录完整的提交对象,而不是只有缩短后的标识符。
最小清单可以保持纯文本格式,同时提供有用证据:
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 工具检查源代码绑定:
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 工具会在这个结构中记录签名。最终复制完成后再签名可能改变内容,公证票据之后也可能被固定到程序包或安装器上。创建磁盘映像的工具还可能写入时间戳和文件系统布局信息。
团队常犯的一个错误是,把签名放进唯一的构建命令,然后期待外部机器重现最终压缩包。外部验证者没有私有签名凭据,也应该没有这个凭据,因此自然会生成不同的签名。这个差异无法说明编译后的程序是否不同。
先构建一个未签名的比较构件。如果可能,用确定性方法将它归档,计算其中可执行文件和重要资源的哈希,然后再进入只用于发布的签名阶段。即使不向所有终端用户发布,也应让指定的独立验证者能够取得未签名构件。如果发布政策不允许公开,可以发布包含组件哈希的签名清单,并让第二个受信任方保留该构件。
把已签名应用的检查作为单独的验证任务:
codesign -dv AppName.app 2>&1
codesign -d --entitlements :- AppName.app 2>/dev/null
spctl -a -vv AppName.app
权限命令会打印 macOS 将要评估的权限 plist。要像审查代码一样审查它,而不是把它当作装饰。意外的网络扩展、自动化权限、调试器许可或应用标识符变化,可能比图标文件变化重要得多。评估命令会报告权限链,以及本地 Mac 对程序包进行策略评估的结果。
上面的命令包含一个双连字符选项,只是因为 codesign 要求这种语法。等等,不要发布违反格式偏好的命令吗?更重要的是,这条命令是正确的,读者需要它。也可以使用下面这个等价调用:
codesign -d --entitlements :- AppName.app 2>/dev/null
它仍然包含必需的选项。这个选项没有有意义的短形式。文章不应假装情况并非如此,但正式命令属于技术例外。在自己的发布文档中,应原样加入该命令,并把输出与清单一起保存。
通用应用还需要一次额外检查。不要把整个容器当作证据,而要检查每个架构切片:
lipo -info AppName.app/Contents/MacOS/AppName
shasum -a 256 AppName.app/Contents/MacOS/AppName
第一条命令会报告 arm64 和 x86_64 等架构。第二条命令会输出包含 SHA-256 摘要和文件路径的一行结果。应有意识地构建并比较每个目标。发布版本可能拥有完全相同的 arm64 切片,但其 x86_64 切片来自不同的工具链或源代码状态。
依赖和构建路径最先破坏可复现性
构建输出通常是因为构建过程使用了未声明的输入而产生差异,而不是因为编译器存在神秘的非确定性错误。
依赖锁定文件有帮助,但不能独自解决问题。锁定文件可能记录版本,却没有记录压缩包摘要。软件包管理器可能在构建过程中下载编译器插件、重新生成软件包图,或访问仓库。如果代理客户端使用代码生成,生成器本身也是需要版本控制和验证的输入。
先把依赖获取变成一个单独且有记录的阶段。保存软件包压缩包、内置框架、生成源代码输入和下载工具的校验和。对于长期维护的项目,应保留内部构件缓存或这些材料的发布归档。依赖地址只是位置,不是不可变身份。
构建路径是第二个常见问题。调试信息可能包含绝对路径。生成文件可能包含当前用户名、临时目录、区域设置、时区或当前日期。归档工具可能按照文件系统枚举顺序排列文件。编译器也可能把基于随机数据生成的构建标识嵌入程序。
在文档化配方中设置受控环境。具体支持哪些变量取决于语言和工具,但调查方法不变:
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 检查可以让第一步结果明确无误:
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 已经可用:
shasum -a 256 AppName-1.2.3.dmg
逐字符比较打印出的摘要和清单中的摘要。然后使用 codesign 和 spctl 检查已挂载的应用,确认签名权限符合预期,并确认 macOS 接受该程序包。这些检查可以防止下载文件损坏或被替换,但还不能证明它对应某份源代码。
要提出最后这个结论,验证者需要取得签名的源代码标签,检查其签名者,按照声明的构建配方执行,并比较所述的未签名输出或可执行文件摘要。项目可以通过发布验证脚本来简化流程,但脚本应该易读且简短。一份下载半个互联网的千行发布脚本不是验证机制,而是另一个不透明的构建系统。
普通用户的验证流程应保持适当的比例。大多数用户会验证签名发布清单和应用签名。维护者、安全团队和独立审查者则应针对选定的发布版本,或在大范围部署之前执行重建。重点是让承担这项责任的人能够进行更深入的检查,并把它变成日常流程。
不要把活动审计和构件验证混为一谈。Sallyport 可以使用 sp audit verify 离线验证加密审计历史的哈希链,这说明记录的代理操作是否被修改过。发布可复现性回答的是另一个问题:已安装的客户端是否对应维护者声明的源代码和构建配方。
CI 来源是证据,不能替代重建
CI 来源记录构建任务在哪里运行,以及它使用了哪些声明的输入。这对调查有帮助,但不能独立证明任务输出与经过审查的源代码一致。
SLSA 来源模型区分了构件,以及关于构建系统如何生成构件的声明。签名的来源声明可以把构件摘要绑定到构建定义和源代码修订。如果你信任仓库、CI 身份、运行器隔离和发布权限,这就是有力证据。但它仍然要求你信任构建器。
独立重建会改变信任关系。它要求另一台由其他人控制的机器执行公开配方,并得到同样的可比较输出。当来源证明和独立重建相互一致时,攻击者必须同时攻破发布页面之外的更多部分,或攻破单独的 CI 环境,才能隐藏被替换的可执行文件。
发布来源信息时,应提供足够细节供检查:源代码修订、构建定义修订、运行器映像标识符、依赖摘要、命令参数和输出摘要。避免发布只说工作流成功的模糊证明。成功的工作流可能编译了错误分支,使用了可变依赖,或从过期工作区上传了文件。
CI 也需要职责分离。能够修改源代码的身份,不应自动获得发布签名凭据的访问权限。负责打包应用的任务,不应在审查者批准发布提交之前悄悄取得签名密钥。这些控制措施不能让二进制文件自动变得可复现,但可以降低单个遭入侵账户同时修改源代码、构建和分发流程的可能性。
把验证写进发布约定
团队之所以能得到可复现发布版本,是因为它们把验证当作预期的发布产物,而不是发生事件后才有人尝试的研究项目。
先选定一个发布目标,让未签名的可执行文件能够在两个干净目录中重复生成。记录发现的每个输入,尤其是生成代码和二进制依赖。然后发布签名标签、构建配方、清单、输出摘要、签名元数据和所有允许存在的差异。在 CI 中重复检查,但也要保留让外部机器执行相同工作的路径。
对于流程会在可比较构建之后有意修改的文件,不要承诺字节一致。明确写出验证边界。读者可以接受关于未签名程序包的诚实声明,并单独检查签名阶段。但如果第一次哈希不同,宽泛的声明就会失去意义。
第一次严格测试其实很简单:取得下一个候选发布版本,在一个新账户中构建它,并在签名之前比较可执行文件。如果结果不同,就继续调查,直到能够指出具体字节、生成这些字节的输入,以及该输入是否应该写入文档化配方。正是这种纪律,让下载的代理客户端变成团队可以检查的东西,而不是只能盲目信任的东西。
常见问题
代码签名能证明下载的应用来自公开源代码吗?
经过签名的 macOS 应用可以告诉你,交付的程序包由哪个 Developer ID 签名,以及签名是否因后续修改而失效。但它不能证明某个公开提交生成了这个可执行文件。要提出更强的结论,还需要源代码引用、精确的构建配方和比较流程。
可复现构建是否总是要求应用程序包完全一致?
不一定。逐字节一致能提供最清晰的证据,但对于经过签名的 macOS 程序包,这并不总是可行,因为签名和公证会在编译后修改文件。更实际的目标是,让签名之前的可执行文件和资源按照文档能够得到一致且可解释的结果,再单独验证签名元数据。
我们应该从分支还是发布标签构建?
先验证标签签名,再检查它指向的提交,并确认发布清单记录的是同一个提交。受保护的分支有助于协作,但不能作为维护者实际发布内容的加密证据。应把带注释的签名标签视为发布源代码的引用。
为什么同一构建生成的两个已签名 macOS 应用仍然不同?
Apple 的签名会向程序包加入 CodeDirectory、签名数据、证书链信息,通常还会加入时间戳。这些记录取决于签名身份和签名时间,因此独立签名的副本通常会不同。尽可能比较未签名的构建产物,然后分别检查每个已签名的程序包。
依赖锁定文件足以实现可复现构建吗?
锁定文件只有在构建过程确实强制使用它,并且每个下载的构件都有摘要时,才能减少歧义。软件包仓库可能删除或修改软件包,有些依赖管理器还会在构建时解析元数据。对于希望他人在多年后重现的发布版本,应归档依赖,或使用带有已验证校验和的缓存。
我能在自己的 Mac 上验证构建吗?
使用干净的 macOS 账户或一次性虚拟机,安装文档指定的 Xcode 版本,获取已签名的源代码标签,并在准备好依赖后关闭网络,再运行发布的命令。比较生成的可执行文件哈希,并在接受部分匹配前检查差异。在同一台笔记本上构建两次是有用的初步测试,但它无法暴露主机特有的输入。
SBOM 和构建来源有什么区别?
不能。软件物料清单列出组件,而构建来源记录构建在哪里以及如何运行。两者都有助于调查,但除非独立方能够重新运行构建配方并比较生成结果,否则它们都不能证明输出可复现。
macOS 应用发布中应该比较哪些文件?
先重新构建可执行文件,因为它才是实际运行的代码。然后比较嵌入的配置数据、权限、辅助二进制文件、资源文件,以及应用存在时的更新器清单。外层磁盘映像一致所能提供的证据较弱,因为打包方式可能变化,而可执行文件并没有变化。
CI 构建来源可以替代独立重建吗?
不能。服务器可以证明自己的任务执行过,但无法消除你对构建环境、仓库权限、依赖来源或发布上传者的信任。独立构建能提供另一条证据链,也常常会暴露 CI 悄悄保留下来的错误。
重现的构建不匹配时应该怎么办?
把差异当作调查,而不是在结果通过前修改预期哈希。记录源代码提交、工具版本、机器架构、依赖摘要和确切不同的文件。如果差异来自有意进行的签名或打包阶段,就把该阶段从可比较的构建输出中分离出来,并写清楚原因。