用 Web Crypto 实现密码管理器级加密:PBKDF2 60 万次迭代 + AES-256-GCM 实战

2026-08-28 · 更新于 2026-09-05 · liaolongdong

用 Web Crypto 实现密码管理器级加密:PBKDF2 60 万次迭代 + AES-256-GCM 实战

用 Web Crypto 实现密码管理器级加密:PBKDF2 60 万次迭代 + AES-256-GCM 实战

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

给密码管理器写加密模块,第一反应往往是引入一个加密库。但如果你做的是浏览器扩展,有个更好的选择:什么都不引,直接用浏览器原生的 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 的推荐值,目标是让暴力破解的单次尝试成本足够高。

两个关键设计:

盐值随机且绑定存储。 每个实例独立随机盐,与密文一起保存。盐不保密,它的作用是确保"同一个主密码在不同安装上派生出不同密钥",让预计算攻击失效。

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

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

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

算法选 AES-256-GCM

密钥句柄(CryptoKey)做了小容量缓存(上限 4 个,LRU 语义),避免同一次批量操作里反复派生;锁定会话时句柄立即清除——内存里不留可以被 GC 之外手段捞出来的密钥材料。

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

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

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

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

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

实现上是先解密校验、再以新盐 + 新迭代派生新密钥、全量重加密成功后才原子提交写入,任何一步失败整体回滚,旧数据保持可用。不会出现"主密码改了,数据解不开了"这种密码管理器的死罪。对应的测试覆盖了成功、中途失败、旧格式兼容三类路径。

把一切输入当敌人

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

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

为什么从 MIT 换到 GPL-3.0

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

最后的诚实声明

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

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


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