用户的 API Key安全存储方法详解(GitHub 文档翻译工具项目方案)

用户自带模型 Key、服务端代调模型,这样的架构把安全责任全压在了存储这一环。Key 一旦泄露,轻则盗刷额度,重则数据外泄,计费账单也会跟着出问题。一开始我考虑过把 Key 放在前端、每次请求直接透传,但那样等于把用户凭据暴露在浏览器里,谁都能从网络面板里抄走;也想过用哈希存储,但哈希不可逆,服务端就没法替用户调模型了。反复权衡后选定了“不落明文、AES-GCM 加密、密钥独立托管、最小权限读取”四件套方案,兼顾安全与可用性。下面把整个设计过程、替代方案对比和踩过的坑写清楚。

一、安全目标

定目标之前先盘了一遍威胁模型:数据库被拖走、日志被翻、代码仓库泄露、运维误操作,每一条都指向同一个结论——任何地方都不能落明文。目标先立住,后面每一步都往这个方向收:

  • 任何地方都不存 API Key 明文;
  • 加密密钥不能与应用代码同库;
  • 业务代码读 Key 时只能拿临时解密结果,不进内存缓存;
  • 凭据使用可审计。

这四条目标里,后两条最容易被忽略。业务代码读 Key 时如果随手缓存,等于把明文平摊到更多地方,攻击面反而扩大了;凭据不可审计,出了问题连是谁在什么时候用的都查不到。目标看起来简单,落地时每一条都要对应到具体的代码约束。

二、加密算法选型

加密方案不是越复杂越好。选型时对比了对称和非对称几类算法,核心矛盾是:服务端必须能解密拿回明文去调模型,所以先排除哈希;Key 是长字符串,非对称加密既慢又笨重,所以排除 RSA;剩下的对称加密里,CBC 要自己补完整性校验,GCM 自带认证标签,省掉一堆容易写错的胶水代码。下表是当时的对比结论。

算法 优点 选择
AES-GCM 认证加密、附带完整性校验 ✅
AES-CBC 经典但需自行处理 MAC ❌
RSA 非对称,适合小数据 ❌(Key 长)

本项目用 AES-256-GCM,每次加密随机 IV。

选 GCM 还有一个实际理由:密文被篡改或数据库损坏时,解密会直接抛错,而不是静默返回坏数据。这个特性在故障排查时救了我们好几次,坏数据一旦混进调用链,比直接报错难查得多。

三、密钥管理

加密算法定了,密钥放哪又是一个问题。密钥和密文不能放同一个地方,否则数据库泄露等于明文泄露。开发环境图省事,用 .env 里的 32 字节随机串;生产环境交给云厂商 KMS,按需解密、留审计痕迹。三个环境的分工如下:

  • 开发环境:.env 中的 32 字节随机串;
  • 生产环境:AWS KMS / GCP KMS / Azure Key Vault;
  • 不写入代码仓库、不写入数据库。

这里踩过一个坑:有人图方便把 Master Key 写进启动脚本,CI 产物里就带了明文密钥,差点一起推到制品库。后来规定密钥只走环境变量或 KMS 注入,代码仓库和数据库里一律不出现,违规提交直接拦截。

四、存储结构

数据库设计上只存密文和 GCM 必需的三样:cipher、iv、tag。明文不进库,主密钥也不进库,库表结构用 Prisma 定义如下。

model ApiKey {
  id        String   @id @default(cuid())
  userId    String
  provider  String
  cipher    Bytes
  iv        Bytes
  tag       Bytes
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@unique([userId, provider])
}

cipher 为密文,iv 与 tag 是 GCM 必需字段。

一个用户对应一个供应商只允许一条记录,重新生成 Key 时直接覆盖密文,历史旧密文自然作废,不会有残留的可用凭据。Bytes 类型则省掉了 base64 转换的开销和歧义,读写都更直接。

五、加密与解密实现

加解密逻辑要简单到能一眼看懂,出错的概率才低。Node 自带 crypto 模块就够用,不需要额外引入依赖。下面这段代码解决的是“怎么把明文安全地变成可存储的密文、再在调用时取回来”的问题。

import crypto from "node:crypto";

function encrypt(plain: string, masterKey: Buffer) {
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv("aes-256-gcm", masterKey, iv);
  const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
  const tag = cipher.getAuthTag();
  return { cipher: enc, iv, tag };
}

function decrypt(cipher: Buffer, iv: Buffer, tag: Buffer, masterKey: Buffer) {
  const dec = crypto.createDecipheriv("aes-256-gcm", masterKey, iv);
  dec.setAuthTag(tag);
  return dec.update(cipher).concat(dec.final()).toString("utf8");
}

实现上有三个注意点:IV 每次都要随机生成,重复使用同一个 IV 会直接破坏 GCM 的安全性;tag 必须保存下来,解密时 setAuthTag 校验,丢了就永远解不开;解密失败会抛错,业务侧要 catch 住,不要返回部分数据给上层。

六、最小权限读取

加密做得再严,如果读 Key 的地方随意暴露,前面的功夫都白费。我们给读取链路设了权限下限,宁可多写几行代码,也不放开口子:

  • 业务服务用单独的 IAM role 读取 KMS;
  • 解密仅在调用模型时进行,结果不写日志;
  • 任何对 ApiKey 的查询只返回 id、provider、创建时间,不返回密文。

解密只发生在真正要调模型的那一刻,结果不写日志、不进内存缓存。查询接口只返回 id、provider、创建时间,密文对业务方不可见,这条约束从接口层就卡死了。

七、轮转与撤销

任何密钥都不能永久有效,轮转是安全方案的一部分。主密钥每年 rotate 一次,轮换期间新旧密钥并行,避免切换瞬间解密中断;用户的 API Key 由用户在界面重新生成,提交后立即覆盖旧密文;删除记录的同时,引导用户去模型厂商后台撤销,双端一起清。整套轮转节奏如下:

  • 主密钥:每年 rotate 一次,rotate 期间新旧 key 并行;
  • 用户 API Key:用户在界面“重新生成”后立即覆盖密文;
  • 撤销:用户删除 ApiKey 时同步调模型厂商后台 revoke。

轮转最容易出事故的环节是切换瞬间,所以新旧密钥并行期一定要足够长,宁可多留几天,也不要让用户遇到解密失败。

八、备份与灾备

备份是另一个容易被漏掉的环节。如果备份文件里是明文,删库再恢复就等于自己把 Key 交出去。我们的约定很简单:备份只备份密文,KMS 密钥做跨区域复制,灾备演练时专门验证一条“备份恢复后能否成功解密”的链路。

  • 数据库备份不存明文;
  • KMS 跨区域复制;
  • 灾备演练时验证解密链路可用。

九、审计与告警

凭据的每次使用都要留下痕迹,否则出了事无从查起。所有 decrypt 行为写审计日志,记录 who、when、provider、user_id;异常高频的 decrypt 触发告警,这往往是盗刷或内部动作的前兆,宁可误报也不要漏报。

  • 所有 decrypt 行为写审计日志:who、when、provider、user_id;
  • 异常高频 decrypt 触发告警;
  • 数据库查询 ApiKey 频率监控。

十、前端交互

安全不止在后端。前端如果处理不当,Key 可能在传输层就泄露。输入框用 type=password 屏蔽明文展示;保存后只显示后 4 位,其余打码;重新输入要二次确认,避免误覆盖;任何场景都不把明文 Key 拼进 URL,防止落入访问日志。

  • 输入框使用 type="password";
  • 保存后只显示后 4 位;
  • 重新输入需要二次确认;
  • 不在 URL 中传明文 Key。

十一、与 OAuth 的区别

一开始有人问,能不能直接走 OAuth,省掉自管 Key 的麻烦。这里把区别说清楚:OAuth 不落用户凭据,服务端只存 access_token,过期可以刷新、可以被服务商吊销;而用户自带 API Key 是长期凭据,服务端必须解密拿回明文去调模型,且无法让厂商自动撤销。能走 OAuth 的场景,自然优先 OAuth;必须接受用户 Key 的场景,就老老实实加密存储。

OAuth 不存用户凭据,只存 access_token,过期可刷新;用户 API Key 是长期凭据,必须加密存储且无法自动撤销(只能让用户在厂商后台撤销)。

这个区别决定了方案的天花板:OAuth 模型下撤销是服务商的事,Key 模型下撤销是用户和我们的共同责任,所以审计和轮转在 Key 方案里不是可选项,而是必选项。

十二、常见错误

这一节的错误都是真实踩过或代码评审时拦下来的,列出来当反面教材,写代码时对着自查一遍能省下不少返工:

  1. 把 IV 写死在代码里:会破坏 GCM 的语义,必须每次随机;
  2. 加密时忘记保存 tag:解密会失败;
  3. 把密文直接 base64 存数据库:可以用 Bytes 减少转换;
  4. 用对称密钥加密又用同一密钥签名:应分离职责。

四条里最容易犯的是第三条,base64 存也不是不能用,但 Bytes 更省空间、语义更清楚,顺手就改了。

十三、回归测试

加密代码改动风险高,必须用回归测试兜底。测试用例覆盖四个方向,保证加解密链路在任意一次改动之后仍然正确:

  • 解密 round-trip:明文 == 解密结果;
  • 错误 tag:解密抛错,不返回部分数据;
  • 错误 IV:解密失败;
  • 错误 Key:解密失败。

后三条专门验证“解不开就失败”,防止任何一步篡改或换错密钥后静默通过。这套测试从第一次合入跑到现在,每轮 CI 都会过一遍,改动加密相关代码时心里踏实很多。

常见问题(FAQ)

Q1:为什么不用 RSA 加密?

Key 太长不适合,AES-GCM 在对称场景下更快更安全。

Q2:能否用浏览器端加密?

可以增加一层保护,但服务端仍需可解密以便调用模型。

Q3:用户 Key 怎么撤销?

在用户界面删除记录,并引导用户去厂商后台 revoke。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
赵其鑫的头像赵其鑫管理团队

相关推荐

返回顶部