解不开的加密链,和不需要解的那条路

解不开的加密链,和不需要解的那条路

一次 WorkBuddy 凭证自动续期的排查记。真正的代码改动只有一百多行;前面那一个小时全花在跟一条自研加密链较劲——最后证明,那一小时里最有价值的判断是"这条路走不通,换个问题问"。

一、故障现场

起因很小:我有个面板在展示 WorkBuddy 的额度用量。某天早上它空了。

查下来是凭证过期——浏览器 session cookie,寿命约 7 天。这类故障有个讨厌的特点:它不报错,只是某天数据不再更新。等我发现时,中间已经断了十个小时。

当时手上还有另一条通道:客户端登录态里的 Keycloak JWT,寿命长得多。用它临时顶上,面板恢复了。但新的死线已经写在日历上——二十多天后,JWT 也会到期。

而拿到新 JWT 的唯一途径,是那条已经坏了的客户端登录态。它坏得很彻底:直接调用客户端命令,它只回一句"需要登录",哪怕我在桌面 App 里重新登录过、哪怕钥匙串权限已经给了。

于是问题从"续个凭证"变成了:

它把凭据锁在哪?钥匙在哪?我能不能配一把?

二、第一反应,也是第一次误判

凭据文件是个 JSON,两个字段(access / refresh)都是密文信封。信封长这样:

{ suite: 1, keyId: "<16 位十六进制>", nonce, authTag, ciphertext }  

不是任何标准格式,是自研的 AES-256-GCM 信封,keyId 是密钥指纹。

既然有指纹,那找密钥就是了。我很快在系统钥匙串里找到一条记录,服务名 <产品名> Safe Storage——这是 Electron 应用的标准命名。读出来的值是 24 个字符的 base64,解出 16 字节。

当时我心想:钥匙到手了。

这是整件事里最大的误判。 因为同一套代码里,密钥载荷是另一个形状:

{ "version": 1, "atRestSecretKey": "<32 字节的 canonical base64,44 字符>" }  

44 字符 vs 24 字符。它们不是同一样东西。 钥匙串里那 16 字节只是给 Electron 自己用的 AES-128 密钥(加密 Chromium 那套存储),而信封要的是 32 字节 AES-256。两条密码学通道,长度不同,混用必然失败。

三、把加密链摊开看

不猜了,去读客户端打包产物。很快看清是全链路四层:

① 系统钥匙串(16 字节)  
② protector key(32 字节),keyId = sha256(key)[:16]  
③ 解开本地 keyblob → file key(32 字节)  
④ 解开凭据信封 → 明文 token  

同时捞到三条硬约束,它们后来决定了整个排查的节奏:

  1. 密钥必须恰好 32 字节,否则构造函数直接抛错;
  2. keyId 必须等于 sha256(key) 的前 16 位十六进制——这是个自带校验的量具;
  3. AAD 是强制的。GCM 是 AEAD,AAD 参与认证标签计算,错一个字节就 final() 抛异常。

第三条最坑:它把"猜错密钥"和"猜错 AAD"变成了同一个症状。GCM 不会给你解出来的乱码,它只会沉默地拒绝。

把 AAD 的构造从混淆代码里一个字节一个字节抠出来,是这一步最硬的活。最终确认是 54 字节(7+1+9+10+4+20+1+1+1,每一段都能对账):

"WB-AAD\0" + [0x01] + lenPrefix(framing标签) + lenPrefix("sym-v1")  
+ uint32(suite) + lenPrefix(keyId) + [framing序号] + optionalUint64(seq) + [final标志]  

其中 framing 标签有个映射表(file / field / record / stream 各对应一个魔数),final 是三态(未定义 0、false 1、true 2)。顺带还挖出另一套并行的非对称体系,magic 不同、framing 标签不同、还多带三个上下文字段——说明这不是随手写的,是分层设计。

四、27 次失败,和一次换位思考

有了这 54 字节正解,我做了个矩阵:3 种 framing 猜法 × 3 种 final 取值 × 3 种密钥派生 = 27 次。

全败。

这时候通常的反应是"还有变体没试到"。但我注意到一件更基本的事:铁律 2 我一次都没用上。

我一直在试"能不能解开",却忘了先问"这把钥匙对不对"。于是做了一次换位:

用 keyId 验密钥,而不是用解密验密钥。

结果很干脆:没有任何一个 32 字节候选的 keyId 对得上。也就是说,钥匙串那 16 字节无论怎么哈希都变不出正确的 protector。

这一步的价值不在"发现了什么",而在排除了一条路——侦探工作里,知道哪条路肯定不通,和知道哪条路通一样值钱。

五、三堵墙

顺着密钥来源继续挖,连撞三堵:

第一堵:载荷不在磁盘上。 我扫了 atRestSecretKey 明文、v10 前缀(Electron safeStorage 的密文标记,含 UTF-16 形态)、base64 变形、SQLite、localStorage 导出——七个应用数据目录全树,零命中。

第二堵:missing-key 不是 bug。 客户端自己写了一份失败登记表,里面明明白白写着 category: "missing-key"。继续翻,发现它根本不碰钥匙串——密钥是另一个进程通过本地 socket 投喂给它的(环境变量里有 socket 路径和一次性令牌)。独立运行时没有这条投喂通道,它当然读不到。这是架构,不是故障。

我在这上面耗了小半个钟头,试图"修好客户端登录态"。方向从一开始就错了。

第三堵:内存被系统锁死。 密钥只在运行中的进程内存里,那最直接的想法就是 dump 内存。lldb 附加的回复是:

error: attach failed (Not allowed to attach to process.)  

硬化运行时 + SIP,合法地拒绝了。这条路不存在。

六、转折:不找钥匙,找明文旁证

三堵墙之后我做了一次问题重写。不是"怎么解密",而是:

有没有一份明文,是我不需要解密就能拿到的?

JWT 有极强的指纹:eyJ 开头的三段 base64url。写个正则扫全盘,连二进制文件都能撞出来。

结果像翻开一个抽屉。凭据目录里躺着一排历史文件,文件名带时间戳——全是明文。它们是升级/迁移时留下的快照:App 在改存储格式前,把旧格式原样备份了。当前生效的那份加密了,历史备份永远是明的。

关键证据,就躺在那儿睡了一个月。

七、判案:它们是同一条链吗

有明文 token 不等于能用。必须先判断一件事:这些文件是不是同一条轮换链的连续快照?

如果是,用旧的会打断当前的;如果不是,就互不干扰。

我解出每个文件的 claims,发现一个决定性事实:每个文件的会话 ID 都不同。

⇒ 独立会话 ⇒ 可以安全地拿一个来用,不会惊动主会话。

再实测存活率:7 个候选里 2 个活着,5 个死透——死的那几个返回的错误很明确:

invalid_grant: Offline user session not found  

原因也清楚了:重新登录会作废旧会话。但每次登录都会留下一批新备份,所以总有活着的。

八、三连 404 之后

有了可用 token,剩下是找刷新端点。源码里写的是 /v2{prefixPath}/auth/token/refresh,其中 prefixPath 是运行时变量。

于是开始猜:workbuddy、wb、copilot、console、desktop……全部 404。

两个错误各犯一次:

  • host 猜错:我一直拿 JWT 里的 issuer 当地址,那是身份提供方,不是业务网关;
  • prefix 猜不出:答案在产品配置缓存里——一个 JSON 字段写死了 "/plugin"。

而日志里正好有一行实证,把完整 URL 打了出来。终点站确认。

先用假 token 打一枪(零风险):

{"code":12153,"msg":"refresh token failed:10000:token format error"}  

不是 404,是"格式错" ——说明请求已经进到业务层了。路由对了。

真 token 上去,HTTP 200。但我又栽在第三个坑上:解析代码报"没有 token",因为我把响应层级写错了——token 在 data.accessToken,不是 data.data.accessToken。前面几次"200 但拿不到 token"的假象,全是这一行的问题。

九、令牌的物理学

拿到新 token 只是开始。要判断能不能永久续下去,得搞清三个数:

项 实测
access token 寿命 28 天(刷新换出);首次登录签发的是 60 天
refresh token 寿命 30 天,每次刷新重置
轮换后旧 refresh token 仍然可用

第三条是意外之喜,也是最重要的保险。typ: "Offline" 是身份系统里"长期后台任务专用"的令牌类型,设计上就不随会话结束而失效。

于是有了一个干净的数学结论:

30 天内刷一次,链条永远不断;而每次刷新会把 refresh token 重置回 30 天。
这是一个自维持的循环。

对比原来:靠客户端登录态拿 token,而那条路已证明是架构而非 bug、修不了。现在这条路完全绕开了客户端,直接跟网关对话。

十、工程化:把侦探活变成定时任务

手工验证通过后,剩下的都是体力活:

  • 一个续期器:只在剩余不足 7 天时动手(平时纯本地判断,不打网络),成功后把主账号 token 原子回写到一个公共文件——那个文件被三个系统共用,改这一个地方,面板、模型代理、签到三方同时续上;
  • 一个定时任务:每天两次,且排在体检之前十分钟——先续期后体检,告警反映的才是"续期之后"的真实状态。反过来就是每天先报一次"即将到期"再自己修好,纯属自欺欺人;
  • 一次监控改造:把盯 JWT 换成盯 refresh token。自动化上线后,被接管的指标会永远正常,继续盯它等于把警报器接在一个不动的表盘上。真正会断的是 refresh token,它一死 JWT 才开始倒数。

十一、翻车,和它救了我的一课

清理状态文件时我写了个删除逻辑,内层循环遍历了全部文件而不是"属于当前账号的文件"。结果处理账号 A 时把账号 B 的正规文件也删了。两个账号,全灭。

救回来靠的正是第九节那个"意外之喜"——offline token 可重用。回到明文备份,用最初那份原始 token 重新刷新,两个账号全部恢复。

两个教训:

  1. 清理代码的危险性被严重低估。删除条件要能自证(文件名 vs 自己的 id),不能依赖"我没遍历到别人"。
  2. 可重用性直接决定容错等级。如果这个接口是严格一次性轮换(很多 OAuth 实现都是),这次翻车就等于永久丢失凭据、必须人工重登。做自动化时,"上游允不允许重放"是个必须先问的问题。

十二、五条

  1. AAD 错和密钥错症状完全一样。所以要找一个独立于解密的验钥手段——这里是 keyId = sha256(key)[:16]。有这个先验,能省掉无穷穷举。
  2. "修好现有组件"之前,先确认它是不是本来就该这样。那个 missing-key 折腾了半天,最后发现是跨进程投喂架构,独立运行本就不该有密钥。判断方向的成本,高于判断技术难点的成本。
  3. 加密不可解时,去找明文的副产物。升级备份、迁移快照、日志、缓存索引——生产系统为了兼容性留下的东西,往往比主路径更敞亮。
  4. 多会话系统里,先做拓扑再做操作。会话 ID 各不相同意味着独立会话,这个判断只花两分钟,却避免了"用旧 token 打断主会话"的风险。
  5. 自动化上线时同步更换监控指标。否则真正出问题的那天,你还以为一切安好。

最后是个好玩的尾巴。修完之后我才想起来测一下另一个接口——面板上"当日请求明细"也一直是空的,它绑的是同一个 7 天 cookie。用同一把 JWT 打过去:

cookie:  401  
Bearer:  200(当日 700 多条)  

它也认 JWT。 于是同一套续期链顺手把明细一起救活了。原来这块只要凭证一过期就必然断,现在它跟着自动续期一起滚动。

回头看,这次排查最贵的不是任何一处技术难点,而是一开始那个"钥匙串里一定有答案"的假设。它让人愿意在一条注定不通的路上多走一会儿——而工程判断里最值钱的能力,可能就是在还有力气的时候承认:这条路走不通,换个问题问。