今天 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)的来由。它不是我列在路线图上的功能,而是哈希这个安全决策亲手逼出来的解药。
换锁要做的事,说穿了很朴素——只换钥匙,别动别的:
换锁四不动
- 角色不动(admin 还是 admin)
- 配额不动(每天 500 次还是 500 次)
- 历史不动(过往用量统计完整保留)
- 只有钥匙换了——旧的瞬间失效,新的当场生效
我特意选了旧钥匙立即作废,而不是给一段"新旧并存的过渡期"。过渡期听起来体贴,但对单人/小团队场景是过度设计:它意味着泄露的旧钥匙还能再活几小时。换锁的全部意义就是"我怀疑这把钥匙不安全了"——既然如此,让它多活一秒都是错的。
落到操作上,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/9 | v1.0 | 把 Copilot 变成本地 API |
| 2/9 | v2.0 | 从本地工具到远程服务 |
| 2/13 | v2.3.0-2.3.1 | 让 OpenClaw 终于能动手了 |
| 2/20 | v2.3.3 | 一个空行引发的「血案」 |
| 4/6 | v2.4.0 | Dashboard 初见 |
| 6/5 | — | 让 Codex 用上自家代理 |
| 6/12 | v2.7.0 | 换锁记:当我把钥匙哈希之后 ← 本文 |

