用 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,而不是加密库
三个理由,按重要性排序:
- 可审计性。 密码管理器的信任基础是"用户能验证你没作恶"。依赖一个几千行的第三方加密库,审计面立刻扩大一圈;而
crypto.subtle是浏览器内核实现,接口稳定、行为公开,源码里的加密逻辑可以一只手数完。 - 攻击面。 供应链投毒是扩展生态的真实威胁。少一个 npm 依赖,就少一个被投毒的入口。
- 性能。 原生实现在 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:
- GCM 是认证加密模式,密文被篡改时解密会直接失败(完整性校验内置),不需要再叠一层 HMAC;
- 每次加密使用 12 字节随机 IV,存储格式为
Base64(IV || 密文),IV 与密文绑定,不存在复用风险; - IV 随机生成由
crypto.getRandomValues保证,坚决不用计数器或可预测来源——GCM 下 IV 复用等于灾难,这条没有妥协空间。
密钥句柄(CryptoKey)做了小容量缓存(上限 4 个,LRU 语义),避免同一次批量操作里反复派生;锁定会话时句柄立即清除——内存里不留可以被 GC 之外手段捞出来的密钥材料。
会话:密钥在内存里活多久
加密做对了,密钥管理做歪了,一样白搭。会话层的设计:
- 有效期可配:默认 24 小时,1 小时到 7 天可选。到期后敏感字段回归密文状态,下次使用重新验证主密码;
- 闲置锁定:通过
chrome.idle监听,系统锁屏即时锁定,闲置超阈值锁定; - 浏览器重启锁定:可选项,
onStartup时强制重锁,防共享设备场景; - 手动锁定:Popup 一键,锁定时同步清掉内存中的密钥句柄与解密快照。
所有锁定路径都必须走到同一个清理函数——这是代码评审时盯得最紧的一条,因为漏掉任何一条路径,"锁定"就成了摆设。
换钥:修改主密码的原子性
"修改主密码"是加密系统里最容易写崩的功能:要用新密钥重加密全部数据,中途失败怎么办?
实现上是先解密校验、再以新盐 + 新迭代派生新密钥、全量重加密成功后才原子提交写入,任何一步失败整体回滚,旧数据保持可用。不会出现"主密码改了,数据解不开了"这种密码管理器的死罪。对应的测试覆盖了成功、中途失败、旧格式兼容三类路径。
把一切输入当敌人
扩展运行在不可信环境里:网页 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/(632 项自动化测试,含加密与换钥路径)。