技术10/10/2026·约 5 分钟

你的单体仓是自己密钥的分发渠道

六个 Apple 签名私钥在仓库里躺了十一个月,到达了每一次 clone、每一个 worktree、每一个 CI 作业。而那条标着 critical 的守卫不可能找到它们。

单体仓对一把密钥做了什么

我们在仓库的一个目录下放着六把 Apple 签名密钥——三个团队,每个各有 development 和 distribution。它们是在 2025-11-29 进去的,那次提交的标题是关于集中签名和 CI 自动导入。动机完全正当:在那之前,配置一台构建机意味着手工传递 keychain,而修法让每台机器变得一模一样。

它们在我们发现时还在那儿,将近十一个月。

一份密钥在单体仓里之所以在类别上比文件夹里的密钥更糟,是因为分发。放在密钥管理器里的密钥,只有你指向它的那些东西够得到。而放在仓库里的密钥,谁都够得到:

  • 每一次 clone,在每一台机器上,永远——包括那台会被偷的笔记本;
  • 每一个并行 worktree,这就是我们的工作方式——一个开发者同时开两个分支就有两份完整拷贝;
  • 每一个 checkout 了这个仓库的 CI 作业,横跨所有 runner,包括不是我们提供的 runner;
  • 每一个 fork、每一份备份、每一个别人顺手做下的仓库归档。

而且它的刷新延迟是一次 pull。没有任何东西被推送,没有任何东西被吊销。任何新加入的机器早就已经拥有它们了。

这一切都不需要入侵。最现实的失败不是攻击者,而是:能访问仓库的人,比应该能签署一个应用的人,多得多——而且从来没人明确指出过这个差别,因为那把钥匙就在一个文件夹里。

那条标着 critical 的守卫

让这件事显得难堪、也最值得写下来的,是我们本来就有的东西。

我们有一套守卫系统。它在代码库里强制架构不变式,它在 CI 里跑,其中一条规则覆盖发布签名目录,而且被标成 critical——最高级别,发布前必须过的那一档。

它在那十一个月里会一直报绿。

那条规则是一条 mutation 检查。它问的是这次改动有没有破坏构建需要的形状——你有没有删掉这次构建需要的 profile、有没有把某个 target 指到一个不存在的证书上。它是一条真正有用的规则,而且它就是它所说的那个东西。但 mutation 检查和凭据扫描器是两件不同的仪器,把它们放在同一枚徽章、同一个严重级别标签下,意味着十一个月里没有人问过我们拥有的是哪一件。

这是我最想防住的一种失效,而且它不是「我们缺一个工具」。我们有工具,它是绿的。教训是:一个在几个维度上挣到了信任的机制,会在它一无所知的维度上也被信任;而严重级别标签是有史以来最高效的信任投递装置。如果你的闸门写着 critical,那么在放松别的任何东西之前,值得先弄清它实际看得见哪一类问题。

公允地说,我们确实有一个密钥扫描脚本。它对某一个基础设施目录下的文本文件逐行扫描,而它本不会发现躺在它没被指过去的目录里的二进制密钥文件。这两套系统共处了一年,谁也没有错;只是我们从来没问过是谁在看。

把它们搬走,并且搬的过程中 fail closed

改动本身很小。密钥被移到一个独立的部署仓库,完全在源仓库之外,CI 不再从目录树里的路径读它们。

有意思的部分是 CI 在找不到它们时的行为:

certs_source: "{{ signing_dir | default('') }}"

- name: 校验 p12 源目录已指定
  assert:
    that: certs_source | length > 0
    fail_msg: >-
      未指定 p12 源目录。用 -e signing_dir=<绝对路径>/signing/ios 指定;
      私钥已迁出本仓库,不再从仓库相对路径读取。

变量默认为空串,并有一条断言在它仍为空时让这次运行失败。这是对「显而易见的另一种做法」的刻意选择——后者是把它默认成新的预期路径,然后让缺失目录在更晚的时候以一个导入错误失败。一个导入错误说的是「这个路径不对」。它不会说「你现在必须从仓库之外提供一个路径」,而后者才是让这次变更立住、并阻止下一个人顺手把旧的相对路径加回来的那句话。

同一次提交里还有两个小决定:

密钥从一台机器上读取再复制,而不是被搜出来。 定位密钥文件的那个任务以 delegate 方式跑在运维主机上,于是私钥从那里的一个本地路径读出、然后复制到 runner。在 runner 上跑 find 在第一天会完全等价地工作——而且也会意味着每个 runner 都需要仓库的历史和那些旧路径才能搜一遍,从而把分发渠道一直开着。

.gitignore 按扩展名写,不按路径写。 *.p12、*.jks、*.keystore,上面一行注释说明密钥现在在哪儿。按路径写的忽略规则在有人需要把文件放到稍微不同的地方那一刻就失效了;按扩展名写的忽略是最后一道线,而不是第一道,而且它对自己的定位是诚实的。

一个从来不是发布身份的 keystore

同一次提交还删掉了另一样东西,而它为什么会在那儿,比这次删除本身更值得说。

Android 的构建图里有一个 release keystore。它在那儿待得足够久,久到所有人都以为我们发布就是靠它。它不是:它被加进来是为了在不方便本地生成密钥的机器上签调试构建,而它从来没签过任何上了架的东西。我们在搬走 Apple 密钥的同一天,把它从构建图里删了。

值得注意的模式在两处都是同一个。两处的发现都不是「我们泄露了一个密钥」——而是「我们有一个看起来承重的密钥形状的东西,而从来没有人问过它到底是干什么用的」。签名密钥是构建图里最像回事的那个物件;它看起来足够重要,以至于没有人会去验证它是否重要。

我们没做的事

那次变更里我们没有轮换密钥。轮换是不可逆的、牵涉每一台机器和过去每一次构建的可复现性的,它值得一次属于它自己的变更和它自己的计划,而不是被夹在一次路径迁移里偷偷带过去。

那是一个真实的缺口,我们想明说:把密钥搬出仓库限制了今后的分发,但对那十一个月的历史毫无作用。密钥当时在每一次 clone 里,它现在仍然在每一次 clone 里。在密钥被轮换、旧的在签发方被吊销之前,我们关掉的那条分发渠道只对新 clone 关闭。

值得说出来,是因为后续的失效模式事后看来非常明显:关掉一条分发渠道、打个勾、把密钥留成有效的——那样的话整件事除了整洁之外什么都没换来。

小结

把版本控制里的每一个密钥,都当作一个已经离开你边界的密钥——因为机制上它就是。每一次 clone、每一个 worktree、每一个 runner、每一份备份里都有它,它不会过期,而拉取是免费的。分发就是泄露;把文件从工作树里删掉,并不会把它从历史里删掉。

审一审你那些 critical 级别的检查到底看得见什么。「构建的形状还完整」不等于「没有凭据存在」,而一个严重级别标签会让两者在你的看板上并排待上一年。问清楚你手里是哪一件仪器——徽章回答不了这个问题。

在新配置上 fail closed,并且带一条解释这次变更的错误信息。空默认值加一条断言只花一行,却能把「这个路径不对」变成「现在这件事得由你来提供」——后者正是阻止有人出于方便把旧路径加回来的那句话。

并且核对一下你的签名材料到底是不是真的承重。那个从未签过任何东西的 Android keystore,看上去和那些确实签过东西的密钥一样重要,而这一点一年里没人发现。构建图里的凭据是一个声明,不是一个事实,而声明是会过期的。

继续阅读