从主密码到密文:加密管线全流程

给密码管理器写加密模块,第一反应往往是引入一个加密库。但如果你做的是浏览器扩展,有个更好的选择:什么都不引,直接用浏览器原生的 Web Crypto API。

Account Password Helper 是一款零云端的本地密码管理器扩展,加密模块(utils/encryption.ts)完全构建在 Web Crypto 之上,没有一行第三方加密代码。这篇文章拆解它的完整设计:为什么这么选、每个参数怎么定、以及那些容易被忽略的边界。

为什么是 Web Crypto,而不是加密库

三个理由,按重要性排序:

  1. 可审计性。 密码管理器的信任基础是”用户能验证你没作恶”。依赖一个几千行的第三方加密库,审计面立刻扩大一圈;而 crypto.subtle 是浏览器内核实现,接口稳定、行为公开,源码里的加密逻辑可以一只手数完。
  2. 攻击面。 供应链投毒是扩展生态的真实威胁。少一个 npm 依赖,就少一个被投毒的入口。
  3. 性能。 原生实现在 PBKDF2 这种大迭代量场景下,比 JS 实现快一个数量级以上。

代价是 API 偏底层、全异步、错误信息简陋——但这正是逼你把每一步想清楚的好约束。

密钥派生:PBKDF2-SHA256,600,000 次迭代

用户的主密码不能直接当加密密钥:它的熵不够,而且需要加盐防彩虹表。标准做法是密钥派生函数,我们选 PBKDF2-SHA256,600,000 次迭代——这是 OWASP 密码存储备忘录自 2023 年起对 PBKDF2-SHA256 的推荐值,目标是让暴力破解的单次尝试成本足够高。

两个关键设计:

盐值随机且明文伴随存储。 每个实例独立随机生成 16 字节盐(32 位 hex),以明文存放在主密码配置中,与密文同库不同键。盐不保密,它的作用是确保”同一个主密码在不同安装上派生出不同密钥”,让预计算攻击失效。

验证哈希做域分离。 校验”主密码是否正确”时,不直接尝试解密数据(解密失败的原因很多,难以区分”密码错”和”数据损坏”),而是对主密码做一次带前缀的哈希校验,前缀 aph-verify| 实现了域分离(domain separation):验证用途和加密用途的输入空间互不重叠,杜绝把验证值当作密文、或把密文拿去撞验证的跨域利用。

数据加密:字段级 + AES-256-GCM

加密粒度上,我们没做整库一锅端,而是字段级加密:每条记录的用户名、密码、URL、备注、TOTP 密钥分别加密。好处很实际——列表页展示标题、图标、标签时不需要解密敏感字段,侧边栏的搜索与渲染可以只碰元数据。

算法选 AES-256-GCM:

  • GCM 是认证加密模式,密文被篡改时解密会直接失败(完整性校验内置),不需要再叠一层 HMAC;
  • 每次加密使用 12 字节随机 IV,存储格式为 Base64(IV || 密文 || authTag)(GCM 的 16 字节认证标签随密文一并落盘),IV 与密文绑定,不存在复用风险;
  • IV 随机生成由 crypto.getRandomValues 保证,坚决不用计数器或可预测来源——GCM 下 IV 复用等于灾难,这条没有妥协空间。

CryptoKey 句柄做了小容量缓存(key 为「用途 + 密钥」,上限 4 个,FIFO 淘汰),把批量加解密里 O(条目 × 字段) 次的 importKey 降为 O(1)——注意它缓存的是导入后的句柄,PBKDF2 派生本身不在缓存范围内;句柄以 extractable: false 导入,不可导出原始密钥字节,且锁定会话时立即清空——内存里不留可以被 GC 之外手段捞出来的密钥材料。

会话:密钥在内存里活多久

加密做对了,密钥管理做歪了,一样白搭。会话层的设计:

  • 有效期可配:默认 24 小时,1 小时到 7 天可选。到期不动任何密文——落盘数据本来就一直是密文,过期做的只是丢会话数据密钥、清解密快照,之后再也解不开,下次使用重新验证主密码;
  • 闲置锁定:可选项且默认关闭,开启后通过 chrome.idle 监听,系统锁屏即时锁定,闲置超阈值锁定;
  • 浏览器重启锁定:同为可选项、默认关闭,开启后 onStartup 时强制重锁,防共享设备场景;
  • 手动锁定:Popup 一键,锁定时同步清掉内存中的密钥句柄与解密快照。

所有锁定路径都必须走到同一个清理函数——这是代码评审时盯得最紧的一条,因为漏掉任何一条路径,”锁定”就成了摆设。

换钥:修改主密码的原子性

“修改主密码”是加密系统里最容易写崩的功能:要用新密钥重加密全部数据,中途失败怎么办?

实现上是先用旧密码把条目、回收站、密码历史全量解密校验一遍,再派生新数据密钥——盐值原样保留、迭代次数原样保留(deriveEncryptionKey 从存储里读那把盐,换盐会让后续解密派生出另一把密钥,数据就永久解不开了;换密码本身已提供足够安全性),最后用新密钥重加密全部三类数据,连同新校验哈希和新会话密钥材料一次 chrome.storage.local.set() 原子落盘,任何一步失败整体回滚,旧数据保持可用。不会出现”主密码改了,数据解不开了”这种密码管理器的死罪。对应的测试覆盖了成功、中途失败、旧格式兼容三类路径。

把一切输入当敌人

扩展运行在不可信环境里:网页 DOM、导入的 CSV、runtime 消息、storage 里的旧数据,全部按不可信输入处理——边界处校验类型、长度、格式,失败安全降级。渲染层禁止 v-html/innerHTML 处理任何来自外部或用户数据的字符串。这些不产生”功能”,但它们是密码管理器该有的肌肉记忆。

两个新近的例子可以具象化这条原则。一个是自动保存弹窗的弱密码/复用风险提示:它是在已解密的全量条目上算出来的派生结论,所以既不进 pending 也不进 sessionStorage(否则用户改了密码会留下陈旧的复用计数),跳页恢复路径负责重算新鲜值;iframe 委托场景下它经 postMessage 跳帧传入,接收方逐字段收窄——weak 只接受严格布尔 true,reusedCount 只接受 1..9999 的整数,非法值直接丢弃。另一个是条目只读详情抽屉里的网址字段:库里存的 URL 对用户可编辑,属于不可信输入,因此它先经 toNavigableUrl 归一化(补默认协议、本地开发域名走 http、显式拒绝 javascript: / data: 等非导航协议)才允许出现在 href 上,返回 null 就不渲染链接。

为什么从 MIT 换到 GPL-3.0

项目在 3.0 版本把协议从 MIT 换成了 GPL-3.0,这是一个有意的决定:密码管理器的价值在于”可审计 + 可信任”,GPL 保证任何人拿了这份代码做衍生产品,也必须以同等开源的方式发布——安全工具的供应链不该有黑箱分支。已发布的历史版本仍按 MIT 存续,GPL 自切换后的新版本生效。

最后的诚实声明

安全的另一面是自知之明:

  • 本工具定位是开发、测试与日常登录场景,不建议在任何浏览器扩展中存放银行、支付类高敏感凭证;
  • 主密码遗忘无法找回,请启用加密备份(.aph 文件);
  • 加密实现全部开源,欢迎审计、欢迎提 Issue——对一个密码管理器来说,被人逐行读代码不是冒犯,是信任的来源。

源码与讨论:GitHub 仓库。如果你也在做本地优先的安全工具,评论区聊聊你踩过的坑。


本文涉及的关键文件:utils/encryption.ts(加密核心)、utils/sessionManager.ts(会话管理)、tests/(2994 项自动化测试,含加密与换钥路径)。

FAQ · 常见问题

常见问题

写密码管理器的加密模块,该用 Web Crypto 还是第三方加密库?

选 Web Crypto,理由按重要性排三条:可审计性,crypto.subtle 是浏览器内核实现、接口稳定行为公开,加密逻辑可以一只手数完;攻击面,少一个 npm 依赖就少一个被投毒的入口;性能,PBKDF2 这种大迭代量场景下原生实现比 JS 快一个数量级以上。代价是 API 偏底层、全异步、错误信息简陋。

PBKDF2 迭代次数定 600,000 次是照什么来的?

这是 OWASP 密码存储备忘录自 2023 年起对 PBKDF2-SHA256 的推荐值,目的是抬高暴力破解的单次尝试成本。同一个项目里修改主密码时盐值和迭代次数都原样保留,因为换盐会让后续解密派生出另一把密钥,数据就永久解不开了。

AES-GCM 的 IV 可以用自增计数器生成吗?

不可以。IV 由 crypto.getRandomValues 随机生成,坚决不用计数器或可预测来源,GCM 下 IV 复用等于灾难,这条没有妥协空间。落盘格式是 Base64(IV || 密文 || authTag),16 字节的认证标签随密文一并存,完整性校验内置、不需要再叠一层 HMAC。