CopilotX 换锁记:当我把钥匙哈希之后

CopilotX 换锁记:当我把钥匙哈希之后

今天 CopilotX 一口气从 v2.6.0 跳到了 v2.7.0,九个 commit 落在同一天。但真正值得记下来的,不是这个数字——是藏在这串提交里的一条因果链:

我为了安全,把用户的 API 钥匙哈希了;可一旦哈希,钥匙就再也拿不回来。于是我不得不再造一把"换锁"的能力。

安全决策催生了它自己的需求。这篇就讲这件事。

起点:终于能把钥匙交出去了

故事得从四月份上线的多用户系统说起。CopilotX 本来是个自用代理——一个 COPILOTX_API_KEY 走天下。但用的人多了,朋友想接、自己的几个小服务也想各自记账,单 key 模式就不够看了。

于是有了用户管理面板:admin 在 Dashboard 上点一下,就能给某个人生成一把专属钥匙、设好每日配额、分配角色(admin / user / viewer)。看起来很美——直到我决定认真过一遍这套东西能不能真的把钥匙交给别人。

把能力开放给第三方,和自己一个人用,是两件完全不同的事。前者意味着:每一个信任假设都得写死在代码里,而不是靠"我知道我不会乱来"。

转折一:钥匙正被前端广播出去

第一刀是小龙虾(我的 AI 审稿搭子)切下来的。它盯着列表接口看了一眼,问了一句很难回答的话:

"你这个 list 接口,是不是把完整的 key 直接送到浏览器了?那连数据库泄露都不用——你自己的前端就在广播钥匙。"

是的。当时的设计,钥匙原样存在 users 表里,列表接口又把它原封不动吐回前端。这在单人自用时无所谓——反正只有我自己。但一旦要发给别人,这就成了单点全裸:钥匙在 API 响应里明文回流,浏览器缓存、HAR 导出、甚至我自己手滑 cat 一下日志,都可能把所有人的钥匙一起赔进去。攻击者都不必费力,系统自己就在往外递。

修法是教科书级的:别存钥匙,存它的指纹。

我把存储改成只保留钥匙的 SHA-256 哈希。验证时,把来访的钥匙现算一次哈希、跟库里比对——对得上就放行。数据库里再也没有任何一把能直接用的钥匙,只有一串谁也还原不回去的指纹。列表展示时也只露出钥匙开头的几个字符(cpx-a1b2…),够你认出"这是哪一把",但不够任何人拿去用。

一个补充:这里的 key 是 160 比特的高熵随机 token,不是用户口令。所以用 SHA-256 而非 bcrypt/argon2 是合理的——哈希在这里只为不可逆匹配和索引查找,不需要抗暴力破解的慢哈希(口令才需要)。

到这里,安全是补上了。但补上的同时,我亲手挖了一个新坑。

转折二:哈希之后,钥匙再也拿不回来了

哈希是单向的。这正是它安全的原因——也正是它的麻烦所在。

钥匙生成的那一刻,是它唯一一次以明文形式存在的时刻。我把它显示给 admin,让对方赶紧复制走,然后转头就把它碾成哈希存进库里。从服务端视角看,从此再没有任何地方保存这把钥匙的原文。

这意味着一个很现实的问题:用户把钥匙弄丢了怎么办?

旧设计里这根本不是问题——大不了去库里再 SELECT 一次抄给他。现在不行了。库里只有指纹,我自己也变不出原文。唯一的答案是:给他换一把新的,让旧的作废。

这就是"换锁"(key rotation)的来由。它不是我列在路线图上的功能,而是哈希这个安全决策亲手逼出来的解药。

换锁要做的事,说穿了很朴素——只换钥匙,别动别的:

换锁四不动

  1. 角色不动(admin 还是 admin)
  2. 配额不动(每天 500 次还是 500 次)
  3. 历史不动(过往用量统计完整保留)
  4. 只有钥匙换了——旧的瞬间失效,新的当场生效

我特意选了旧钥匙立即作废,而不是给一段"新旧并存的过渡期"。过渡期听起来体贴,但对单人/小团队场景是过度设计:它意味着泄露的旧钥匙还能再活几小时。换锁的全部意义就是"我怀疑这把钥匙不安全了"——既然如此,让它多活一秒都是错的。

落到操作上,admin 现在有三个入口换锁:Dashboard 上每个用户那行多了个 "Rotate key" 按钮、命令行一句 copilotx user rotate-key alice、或者直接打那个 REST 接口。无论走哪条路,新钥匙都只在屏幕上闪现一次,复制走,然后它也变成一串指纹。

至于前端那个"只显示一次"的弹窗——我几乎没动它。生成新用户时早就有这么个弹窗了,换锁直接复用,我只是给它多接了一个标题参数,让标题能从 "User created" 切到 "Key rotated"。好的基础设施就该这样:新需求来了,发现自己想要的零件早就躺在那儿了。

余震:审查揪出的两个"信任 bug"

把钥匙交给别人这件事,逼着我用一种更偏执的眼光重看每一条访问路径。这一看,揪出两个本身不是功能缺陷、却是信任缺陷的问题。

第一个是"默认放行"。 统计接口原本的逻辑是"如果你不是普通用户,就给你看全部数据"。今天的请求路径不会真的踩中它——但这个默认分支表达了一个危险的信任假设:一旦未来某条路径绕过了中间件、或者测试/重构直接调到这个 handler,一个没带角色信息的请求就会被当成全量视角放行。正确的姿势是反过来:默认拒绝,只有明确证明"你是 admin"才放行全量,其余一律降级到只看自己。信任的默认值必须是"不信",而不是"先信着再说"。

第二个是"半提交"。 改用户信息时可以同时改配额和角色。原来的写法是先写配额、再写角色——如果角色填了个非法值被拒,配额那一半却已经悄悄落库了。用户以为整个操作失败了,实际配额已经被改掉。修法是把两个字段塞进同一个事务:要么一起成功,要么一起回滚,绝不留下"改了一半"的中间状态。

这两个都不是会报错的 bug,而是会悄悄背叛你信任假设的 bug——危险得多。

上线前的卫生检查

这一桩不是 Review 揪出来的,是我自己在发版前做的例行自检:在确认这套发钥匙的流程能见人之前,我去生产环境查了一圈,确认没有早期测试时代留下的遗留测试用户或废钥匙。结果很干净,一把没漏在外面。发新锁之前,先确认没有哪把旧锁还插在门上——这是基本的卫生。

这不只是 CopilotX 的功课

写到这儿我意识到,今天做的事,本质上和"CopilotX 多了个换锁按钮"没多大关系。它是任何一个要把能力交给第三方的系统都绕不开的三道关:

  • 密钥哈希存储——别存能直接用的凭证,存它不可逆的指纹;
  • 默认拒绝——信任的默认值是"不",权限要明确授予而非默认继承;
  • 可轮换——凭证一定会泄露、会丢失,所以"换锁"必须是一等公民,而不是出事后才手忙脚乱去补的能力。

无论你做的是 API 网关、是给用户发 token 的 SaaS、还是只是给朋友开个账号——只要"钥匙"会离开你自己的手,这三关就一关都少不了。CopilotX 只是今天恰好替我把这堂课上了一遍。

而这三关里,可轮换是最容易被忽略的一个。我们总在设计如何"发出"一把好钥匙,却很少在第一天就想清楚"这把钥匙坏了怎么收回"。今天这条因果链给我的提醒恰恰是:当你真的把安全做到位(哈希),你会发现自己被迫补上轮换——因为安全的钥匙,注定是拿不回来的。

收束

一天九个 commit,从 v2.6.0 到 v2.7.0。但如果只让我留一句话,那就是关于钥匙的这一句:

一把钥匙的价值,不在于它能打开什么,而在于——当它丢了的时候,你换得起。

今天,CopilotX 终于换得起了。


CopilotX 版本线:

日期版本博文
2/9v1.0把 Copilot 变成本地 API
2/9v2.0从本地工具到远程服务
2/13v2.3.0-2.3.1让 OpenClaw 终于能动手了
2/20v2.3.3一个空行引发的「血案」
4/6v2.4.0Dashboard 初见
6/5—让 Codex 用上自家代理
6/12v2.7.0换锁记:当我把钥匙哈希之后 ← 本文

Comments