一款 Rust 编写的开源 CLI 工具,将加密密钥存入每台 Mac 的 Secure Enclave,团队成员各持私钥、Pull Request 即添加成员、移除成员自动重加密,无需共享主密钥也无需额外部署密钥服务器。
好吧,我做了一件我一直想做的事:我在 crates.io 和 Homebrew 上发布了 sopsy——我用 Rust 编写的第一个开源包。经过二十多年交付 Ruby、一些 C 和大量 Bash 脚本,看着 cargo publish 在我自己写的包上变绿,感觉真的很棒。我允许自己小小地庆祝一下,在 Rust 不可避免地再次让我见识到自己的渺小之前。
而且,就像现在所有"酷小孩"一样,它当然有自己的花哨网站,因为显然一个 CLI 如果没有域名就不算真实存在。现在就访问 sopsy-cli.dev,或者等你读完本文再去。
这是什么,你为什么应该关心?
sopsy 让你的团队可以直接把密钥——加密后——提交到 git,其中每个开发者持有一把存储在自己 Mac Secure Enclave 中的私钥,解密时需要指纹验证。没有共享的主密钥(这是上一次尝试最终失败的根本原因,下文会详述)。不需要运行或付费的密钥服务器。没有在 Slack 上悄悄流传的明文 .env。添加一个队友只需要一个 Pull Request;移除一个会自动重新加密所有文件。
谁需要它?需要对比特币 git 中保管的开发者密钥拥有严格、可撤销控制权的团队——中小型工程团队、开源项目,以及不想专门搭建 Vault 来避免手动传递 .env 文件的安全意识较强的初创公司。它的设计初衷是自下而上:一个开发者安装它,在某个仓库试用,然后带动整个团队。目前优先支持 macOS,因为涉及到硬件层面的功能。
核心思维转变,为忙碌的人压缩版:
共享的对称密钥(或者 .gitignore)要求你守护一个每个人都复制的密钥。sopsy 让每个人持有自己的硬件密钥——当成员变更时自动重新加密整个团队。
首先,坦诚地说清楚,因为安全工具很快就会变得令人讨厌:sopsy 没有重新实现任何加密算法。它建立在三个优秀、无聊、久经沙场的工具上,这些工具学习和使用起来相当烦人,而在我的看来,sopsy 让它们变得更容易接受:
SOPS — Mozilla 的密钥操作器,它巧妙地加密文件中的值而保留密钥可读,所以 diff 仍然能告诉你"DATABASE_URL 改变了"或者"OPENAI_API_KEY 是什么时候添加的",而不会泄露具体改成了什么。
age — Filippo Valsorda 的现代加密工具,没有会出错的选择项,是 GPG 的精神解药。
age-plugin-se — 让我感到温暖的部分:它生成一个 age 身份,其私钥在你的 Mac Secure Enclave 内部生成,物理上无法离开它。解密需要 Touch ID。密钥不在磁盘上供恶意软件查找,因为它从未在磁盘上出现过。macOS 有一个 Secure Enclave 静静地等在那里,可以解决正是这个问题的这件事,对我来说是一个巨大的惊喜。但与此同时,它又没那么令人意外。
sopsy 是一个友好的 Rust CLI,编排这三个工具:它引导初始化一个仓库,管理谁能解密,当成员变更时重新加密所有内容,并提供一个 CI 门禁,在明文密钥溜进来的瞬间让构建失败。SOPS 是引擎;sopsy 是方向盘、安全带,以及那个告诉你即将做蠢事的小仪表盘指示灯。
它的形态是这样的:

整个技巧在于底部的那个箭头。加密只需要队友的公钥,所以可以静默离线进行。解密需要私钥,私钥存在于芯片中,只对你的指纹负责。密文安心地待在 git 里;解开它的东西永远不会。
你按预期的方式启动一个仓库:
git init my-app && cd my-app
sopsy init # 检查工具,铸造一个 Secure Enclave 身份,写入配置
sopsy edit .env.encrypted # 打开你的 $EDITOR;解密进来,保存时重新加密(Touch ID)
sopsy check # CI 门禁:如果任何明文密钥被追踪则失败
git add .sops.yaml .sopsy.yml .env.example .env.encrypted .gitignore
git commit -m "Encrypted secrets, managed by sopsy"
sopsy init 打印你的公开接收者——一个 age1se1… 字符串——那是你身份中唯一需要共享或提交的部分。日常使用中还有 sopsy decrypt(和 sopsy secrets decrypt),将明文流式输出到 stdout,所以你可以把它接入 direnv,在每个 shell 中解锁整个环境一次,而不必在每个命令中去对抗它:
# .envrc — cd 进去时一次 Touch ID,然后你的密钥就在那里了
eval "$(sopsy decrypt .env.encrypted | sed -E '/^#/d; /^$/d; s/^([A-Z])/export \1/g')"
如果每次读取都要 Touch ID 让你厌烦——你用 direnv,或者在一个项目上运行十几个窗口——你可以不带生物识别门禁来铸造 Enclave 密钥:sopsy join "Your Name" --without-touch-id。私钥仍然永远不会离开 Enclave,仍然绑定在设备上;只是不再弹出提示。这是经过深思熟虑的权衡:少用拇指,少些繁文缛节。
精彩之处来了:团队模型
共享对称密钥没有"谁"的概念。sopsy 有,而这正是它能为团队工作的全部原因。两个文件承载状态:
.sops.yaml — 由 SOPS 本身消费:任何新密钥都会被加密到的公钥接收者列表。
.sopsy.yml — sopsy 自己的账本:人名,谁拥有哪个密钥,生命周期状态,一份小型审计跟踪(谁在什么时候请求了访问,谁在什么时候批准了他们),以及 break-glass 标记。一个提交的 .sopsy.sha 校验和使得手动编辑变得可察觉篡改,而不是被默默信任。
入职是自助式的,这才是真正改变团队感受的部分。新人不需要追着任何人要密钥;他们自己铸造密钥,然后在 Pull Request 中发送公钥的一半:

注意一个"待处理"的加入授予了什么:什么都没有。新人的密钥还不在 .sops.yaml 中,所以它无法解密任何东西——请求只是一个位于 PR 中的公钥。(sopsy join 也被别名为 sopsy request-access,如果这在你的团队文档中读起来更顺眼的话。)现有成员审查它,确认该密钥确实属于 Anna——你通过 out-of-band 方式确认,就像确认 GPG 指纹一样——然后 sopsy approve 完成剩余工作:添加密钥,用 sops updatekeys 为新的接收者集合重新包装数据密钥,并将 Anna 切换为活跃状态。离职是镜像操作。没有共享密钥,没有主密码,没有"我们也许应该轮换一下那个了"。
因为每个开发者的私钥都绑定在硬件上,所以有两个刻意设计的安全阀来处理指纹不可用的情况:
Break-glass — 你生成一次、离线存储在 1Password 中、并注册为紧急接收者的便携式 age 密钥。如果每个人的笔记本都掉进海里,那个离线的密钥仍然可以打开并重新加密仓库。sopsy 会唠叨你直到你创建一个,而这个星期之后我完全理解了为什么。
CI 解密 — sopsy recipient ci 运行相同的引导仪式,给你的流水线一个便携式密钥:一个名为 SOPS_AGE_KEY 的 CI 密钥,你的 workflows 就能解密了。Secure Enclave 正确地永远不会在无头模式下工作——所以这是 CI 在没有 Secure Enclave 的情况下读取密钥的方式。
一点历史:我如何走到这里
九年前我写了一个叫 sym 的小工具,并写了一篇关于它的博客文章。
我在那篇文章开头用了一个抱怨,这个抱怨老化得令人不安地好:把你的密钥挡在 git 之外,放在一堆 .gitignored 文件里,给你一种虚假的安全感,却让你失去版本控制的所有好处。没有历史。没有回滚。没有 diff。还有"嘿,你能再把 .env 用 AirDrop 发我吗?我的那个已经过期三周了"这种永恒的团队税。
那时候的梦想和雄心与今天不完全一样,但很接近:我只是不想把明文密钥放在我的仓库里,即使它们被 git 忽略了。理想情况下,我想把它们加密后提交进去。那时候 pre-commit hooks 已经存在了,所以把明文密钥提交到 git 不是什么大问题。但是"通往许多王国的钥匙"以明文形式散落在我的文件系统里,等着有人闯入偷走它们——这让我烦恼不已,持续不断。所以我写了 sym——一个带几个 macOS 特性的对称加密 Ruby gem。以 2017 年的标准来说,它不算差。
sym 是一个 Ruby gem,底层使用 OpenSSL 加密,所以这部分是可靠的。你生成一个对称密钥,然后将你的密钥加密为一个大文本块(加密后的数据也会做 base64 编码)提交到仓库。在 1Password 里有一个主密钥,当你要"引入"某个人时,你拿到那个主密钥,对方会使用 sym 将密钥导入 OS X Keychain,在此过程中再用一层密码保护加密将密钥包裹起来。现在你可以用 git 提交密钥了,而在你的机器上解密需要你的密码(在导入时设置的密码)。这也是一个额外的好处。但是,这个单一的主密钥现在成了最脆弱的一环——一旦泄露,整个方案就完全失去意义。从这个意义上说,有些安全人员会说:"很棒的工具,给你一种受保护的安全感,实际上并没有真正提供保护"……
它也有几个便利之处——你可以要求将密码缓存在本地 memcached 实例中,不过我比较懒,直接明文缓存了。安全靠模糊,对吧?
尽管如此,这仍然比所有人共享一个包含几十个 API 密钥的纯文本文件要好得多——那些密钥如果被盗,可能会被用来让公司产生大量费用,更不用说访问一些私有数据了,那会更糟。
所以?2017 年这样就够了,我参与的几个项目采用了这个方案。它没有大火,因为不久之后 Rails 推出了 credentials,虽然缺少密码保护和 Keychain 存储加密密钥的便利性,但最终允许你在仓库中保存加密的凭证。随着 Rails 的流行,没有人与之竞争。
最近在一个项目中,.env 文件包含近百个各种密钥和 API 密钥,我心想,也许 2026 年我们可以做得更好?
在与 Claude 和 ChatGPT 进行了无数次对话,讨论现有的最佳密钥加密工具(特别是开发环境中的工具)之后,我一直在回到同一个问题。
云端的生产系统已经成熟了。如果你在 AWS 上运行,你的密钥在 KMS。如果你是一个 Terraform 爱好者,大概会用 Vault。但本地、在你的开发 MacBook 上,避免将"通往王国的钥匙"以明文形式到处乱放的首选方法是什么?
肯定已经有人写出了加密开发密钥、且不依赖单一对称密钥的工具了吧?嗯,也许确实写了。但我们三个人(两个 AI 智能体和我自己)找不到任何能做我想做的事、又具备我心目中的易用性的东西。
在发现了开发者工具中的一个潜在缺口,并收集了关于现代加密和密钥管理工具的信息之后,我制定了一个计划。所以在过去的几天里,我和 Claude 一起构建了 sopsy——一个基于 Rust 的 CLI,用于实现我 2017 年就想做的事,只是当时在最重要的部分无法绕过手写代码。
一系列优秀的 bug
现在是有趣的部分——也是诚实的一部分。我在几天里与一个 AI 编码智能体配对构建了 sopsy,这里有一个我一直在思考的细节:智能体写的代码干净、有良好的测试,通过了它自己的测试套件,几乎每一个真正的 bug 都是在人类真正使用这个东西的那一刻才被发现的。不是在 CI 中发现的。是在我手上、在两个真实的 macOS 账户上、用我的拇指实际按下去的时候发现的。那种差距——"测试通过了"和"对一个人来说能用"之间的差距——是本节要讲的核心故事,坦白说也是所有评估的核心故事。测试套件中的确定性不等于现实世界中的正确性。
以下是值得一讲的几个 bug。
1. 不存在的密钥
sopsy init 的第一次真实运行以最令人困惑的一类失败告终:显示成功,然后什么都用不了。它生成了我的 Secure Enclave 身份,加密了文件,报告了胜利——但之后每次解密都失败,提示 age: identity did not match any of the recipients。
原因是关于 age-plugin-se 如何工作一个美丽的小误解。当它生成一个密钥时,它会给你一个长长的 AGE-PLUGIN-SE-1… 字符串。人们很容易——但这是错误的——把这个字符串当作一次性的,因为"私钥在 Enclave 里,谁在乎呢"。但这个字符串是句柄:是 SOPS 需要向 Enclave 请求来解密任何东西的引用。我们生成了它,然后又把它丢掉了。私钥安全地待在 Enclave 里,没错——但完全无法访问,就像一个保险箱的号码你掉进马桶不小心冲走了。
修复方法是值得内化的一个区别:句柄不是密钥。它只能在这台设备上、在这个指纹后面使用,所以写入磁盘是完全安全的——而且你必须这样做,否则 Enclave 密钥就毫无用处。sopsy 现在将它持久化到标准的 sops 密钥文件,并让每个 SOPS 调用都指向它。私钥仍然永远不会离开芯片。只是指向它的映射需要存在。
2. 永远运行的 init
早期,一个队友(我,换了一种心情)在错误的目录下运行了 sopsy init——那不是一个全新的仓库,所以 git 愉快地向上查找,决定包含我的整个主目录作为仓库。sopsy 随后试图找到每一个加密文件来重新加密……通过读取仓库根目录下的每个文件。读取 $HOME 下的所有文件。它还在继续,风扇转动,几分钟后以及几个 GB 的 node_modules 之后。
无限制的"扫描一切"是一个隐藏的 bug,直到有人将它指向一个足够大的目录,然后它就会挂起,而不是报错——这是最糟糕的一类。修复方法是停止遍历文件系统,改为直接展开声明的加密文件 glob 模式(一个永不跨越 / 的 *),这样扫描只触及这些模式实际命名的目录,不会触及其他任何东西。而且 init 现在不会静默地将你的主目录作为密钥仓库——它会停下来询问。
3. 无法提交的密钥(然后是无法共享的密钥)
这是一个分两部分陷阱,我最喜欢它,因为每一半在没有另一半时都是不可见的。
sopsy 编写的默认 .gitignore 有点过于贪婪。它忽略了 .env.* 以防止零散的 dotenv 文件进入 git——很合理!——但 .env.example.encrypted 也匹配 .env.*。所以你想要提交的那个文件被悄悄地从版本跟踪中移除了。这是第一部分。
第二部分:当一个新成员被批准时,重新加密步骤通过 git ls-files 枚举文件。合理,除了这个命令故意跳过被忽略的文件——所以第一部分刚刚隐藏的加密产物在重新加密期间也被跳过了。新成员被添加到了 .sops.yaml,一切报告成功,然后他们无法解密其中一个文件,因为他们从未被添加到其中。
教训是一个干净的小不变量:加密产物是一等公民的提交文件,不容商量。.gitignore 现在显式地拯救每一个 *.encrypted,不管怎样,重新加密通过 glob 模式枚举工作树来获取密钥——而不是询问 git,因为 git 对什么是值得提及的有自己的看法。当两个合理的行为组合成一个错误的行为时,那个不变量是错误的或不完整的。Bug 就藏在那里。
4. sudo 无法触碰 Enclave(而这正是重点)
我用一台笔记本上的两个 macOS 账户测试了双人流程,并用 sudo su - arc 在它们之间切换。解密每次都失败,没有 Touch ID 提示,只有同样的 flat "no identity matched"。我确信这是一个 sopsy 的 bug。它不是 sopsy 的 bug。
Secure Enclave 只会在用户的活动 GUI 登录会话中释放密钥。sudo、su、SSH——它们都无法调起生物识别提示,所以 Enclave 直接拒绝。只要我用 Fast User Switching 实际在屏幕上登录 arc,提示就出现了,我的拇指做了它该做的事,密钥就打开了。
这不是一个缺陷,这是威胁模型在正常工作。一把可以通过 SSH 使用的密钥,就是攻击者可以通过 SSH 使用的密钥。硬件绑定、需要临场的解密正是你对生产密钥想要的——而这正是便携式 break-glass 和 CI 密钥存在的原因,用于 Enclave 正确拒绝的无头场景。Touch ID 也是按账户的:每个用户注册自己的指纹,回落到该账户的登录密码。
还有一些小问题——我自己的测试愉快地向我的真实密钥库写入假密钥,Homebrew 安装在 $PATH 上遮蔽了刚构建的 cargo 二进制文件,所以我一直在测试旧版本——但以上四个是教会我东西的。每一个都通过了绿色测试套件,然后在与人接触时挂掉了。烦人吗?是的。有用吗?也是的。
完整功能集,供快速浏览:
一键引导——sopsy init 写入 .sops.yaml、.env.example、加密的 .env.encrypted、.gitignore 安全规则和 .sopsy.yml。幂等性,所以重复运行始终是安全的。
Secure Enclave 身份 — 你的私钥在 Apple Silicon 硬件内部生成,由 Touch ID 保护,无法被读取或导出。如果希望绑定设备但不弹出提示,可以使用 --without-touch-id。
带审计日志的自助成员管理 — sopsy join(别名 request-access)记录一个待处理请求;任何活跃成员都可以 sopsy approve。.sopsy.yml 保存谁发了请求、用户名、时间,以及谁在何时批准;sopsy recipient list 打印全部记录。
自动重新加密 — 每次 add / remove / approve 都会通过 sops updatekeys 重新包装所有密钥,这样接收者集合永远不会与密文脱节。
友好的编辑体验 + 可脚本化的加密 — sopsy edit 在你的 $EDITOR 中打开加密文件,并自动检测文件类型;sopsy encrypt / sopsy decrypt(以及 secrets 表单)做一次性、direnv 友好的加密,输出到 stdout 或文件。
紧急密钥 + CI 仪式 — sopsy recipient break-glass 生成一个离线紧急密钥;sopsy recipient ci 生成一个便携式 CI 解密密钥(只需一个 SOPS_AGE_KEY secret,你的流水线就可以解密)。
防篡改配置 — 提交到仓库的 .sopsy.sha 校验和覆盖 .sopsy.yml + 管理密钥,写入时刷新,读取时校验,因此手动修改会被暴露,而不是被静默信任。
CI 门禁会咬人 — sopsy check 执行卫生检查并在任何跟踪的明文存在时非零退出。它不需要密钥,所以可以在 Linux runner 上运行。
sopsy doctor — 一个分组、彩色的健康报告,检查你的工具、Secure Enclave、Touch ID 和仓库状态,可以直接粘贴到 GitHub issue 中。
如果你现在在 git 中保管密钥 — 或者你一直在用共享的 .env 并且暗自担心 — 我真的很想听听你的团队是如何处理这个问题的。你在用什么?什么阻止你把加密密钥和代码一起提交?安装它(brew install kigster/tap/sopsy 或 cargo install sopsy),指向一个临时仓库,告诉我哪里出了问题。
在下方留言 — bug 报告、"你重新发明了 X"、"为什么不用 Y" 都同样欢迎。如果它对你有用,在 GitHub 上点个星可以帮助更多人找到它。
去加密点什么吧。然后试着提交它。这一步现在应该感觉很好了。
— Konstantin Gredeskoul
San Francisco, CA, 2026 年 6 月 28 日。
为了完全公开:sopsy 是与 AI 编码智能体配对几天写出来的,这个体验是整篇文章的潜台词。那个智能体说实话是个出色的程序员 — 干净的 Rust、真实的测试、全绿。但它自己一个 bug 都找不到,因为上面每个 bug 都需要一个人、一根拇指、两个登录会话,还有不少骂骂咧咧。代码是 AI 写的;正确性和验证来自于我对 Claude 大喊大叫,不止一次地压缩上下文,总体上对它相当恼火。保持一个人在循环中,尤其是那个拿着手指和指纹的人。
sopsy on GitHub — 源码、安装说明,以及 owner/member 指南。
sopsy on Crates.io — 你会从这里安装它。
SOPS · age · age-plugin-se — sopsy 立足的三个工具。
Dead Simple Encryption with Sym — 2016 年的前身,以及仍然成立的问题陈述。
Evals: The Unit Tests for the Non-Deterministic Parts of Your App — 更多关于"测试通过"和"它能工作"之间的差距。
进一步行动,你可以考虑屏蔽此人和/或举报滥用行为