Web Crypto API 如何正确管理浏览器端密钥?
区分哈希、加密、签名和密钥派生,使用 SubtleCrypto 设计密钥生成、导入、存储和轮换流程,并说明浏览器端密码学的安全边界。
简答
- 哈希、加密、签名和密钥派生解决不同的问题,不能互相替代。
- 随机数用 crypto.getRandomValues,密钥用 SubtleCrypto 生成并尽量设为不可导出。
- 从密码派生密钥要用 PBKDF2 等派生算法,不能用普通哈希。
- 整个 crypto.subtle 都要求安全上下文;密钥要设计版本和轮换流程。
应用想在浏览器中加密草稿、验证下载文件或派生会话密钥,开发者常把“字符串做 SHA-256”称为加密,或者把密钥放进 localStorage 后认为数据安全。Web Crypto API 提供的是底层密码学原语,真正安全取决于用途、密钥生命周期、认证方式、随机数和服务端协议。
哈希、加密、签名和派生解决不同问题
- 哈希:把输入映射为摘要,用于完整性或指纹;不能从摘要还原原文,也不等于密码存储方案。
- 对称加密:同一把秘密密钥加密和解密,适合保护数据机密性;必须使用认证加密模式并安全保存密钥。
- 签名:私钥签名、公钥验证,证明持有者和内容完整性;不是把内容隐藏起来。
- 密钥派生:从密码或主密钥派生工作密钥,需要盐值、成本参数和明确协议。
把密码直接哈希一次、把 AES-CTR 当作完整加密、把公钥当成秘密,都会造成错误的安全假设。
生成随机密钥和随机数
const key = await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256,
},
false,
['encrypt', 'decrypt'],
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode('需要保护的草稿');
// 绑定记录身份;解密端必须从可信业务上下文取得同样的值。
const additionalData = new TextEncoder().encode('draft:42:format:1');
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, additionalData, tagLength: 128 },
key,
plaintext,
);
const restored = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv, additionalData, tagLength: 128 },
key,
ciphertext,
);
console.log(new TextDecoder().decode(restored)); // 需要保护的草稿
const damaged = new Uint8Array(ciphertext.slice(0));
damaged[0] ^= 1;
try {
await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv, additionalData, tagLength: 128 },
key,
damaged,
);
throw new Error('预期认证失败,但解密成功');
} catch (error) {
if (error.name !== 'OperationError') throw error;
console.log('密文被修改,拒绝使用');
}
AES-GCM 的 IV 不要求保密,但同一把密钥下不能重复使用;它通常使用 12 字节随机 IV,具体限制要按实现和协议固定。密文应和 IV、版本、算法参数一起保存,解密时还要验证认证标签。不要使用 Math.random() 生成 IV、盐或密钥,也不要截断密文或忽略 decrypt 的失败。
extractable: false 可以限制密钥导出,但不是对恶意同源脚本的绝对防护;同源 XSS 仍可能调用密钥进行允许的操作。浏览器端密钥保护必须和 CSP、Trusted Types、依赖治理及会话安全一起设计。
这段代码可以在支持 Web Crypto 的安全上下文中作为模块运行。Web Crypto 的 AES-GCM 输出已包含认证标签,decrypt 会校验它;无需自己比较标签,更不能验证失败后仍展示解出的部分数据。AAD 不加密,只参与认证,不能把秘密放进去并期待它被隐藏。错误的密钥、IV 或 AAD 也可能导致认证失败,生产界面不应仅凭 OperationError 就断言用户遭遇攻击。
随机 IV 是概率性避免重复,不是无限使用的保证。高频、多设备共享密钥的协议必须规划调用量、唯一性和轮换,不能把同一个示例 iv 重用于多次 encrypt。重复解密同一条密文则不属于重复加密使用 IV。
密钥存储和导入
CryptoKey 可以放入 IndexedDB(具体可序列化性和浏览器支持需实测),但存储它不等于密钥已经脱离当前源的信任边界。任何能够执行同源脚本的代码都可能尝试使用可访问的密钥。localStorage 适合保存非敏感版本或状态,不适合保存原始密钥、会话令牌和密码。
导入外部 JWK、原始字节或 PEM 时要校验算法、用途、长度和版本,不能让输入决定任意算法:
const publicKey = await crypto.subtle.importKey(
'jwk',
jwk,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['verify'],
);
示例只允许公钥验证用途。生产协议应确认曲线、签名编码和哈希算法与服务端实现一致;不要因为导入成功就信任密钥来源,密钥绑定和撤销仍属于应用协议。
密码派生不能用普通哈希替代
用户密码熵通常较低,不能直接作为 AES 密钥,也不能只做一次 SHA-256。浏览器可以使用 PBKDF2 等 API 进行派生,但成本参数、盐值、升级和服务端验证要由完整协议定义。更高风险的账号密码存储应交给服务端使用适合密码哈希的方案;浏览器端派生不应让密码在网络或日志中暴露。
加密草稿的密钥恢复也需要产品设计:用户清除浏览器数据、换设备、忘记密码或撤销会话时,数据是否仍可恢复?如果没有密钥备份或服务端密钥包装,纯客户端加密数据可能永久无法解密。不要把“无法恢复”当作默认安全优势而不告知用户。
版本、轮换和关联数据
加密格式应包含版本、算法、密钥 ID、IV 和密文长度边界。密钥轮换时,新数据使用新密钥,旧数据按版本解密后重新加密;不能把新密钥直接覆盖旧密钥导致历史数据不可读。关联数据(AAD)可以认证租户、记录 ID 或版本,但这些字段必须在解密前由业务上下文确定,不能信任密文中自称的租户。
安全上下文、兼容性和性能
整个 crypto.subtle 都要求安全上下文(HTTPS 或 localhost),crypto.getRandomValues() 不受此限制;Worker 中同样可用。Passkey 登录属于 WebAuthn,不是 Web Crypto 的用途。算法、曲线、密钥格式和浏览器实现存在差异,不能只在一个浏览器成功就宣布兼容。大文件加密应评估流式处理、分块协议和内存峰值;subtle.encrypt 接收完整 ArrayBuffer 时可能一次性占用大量内存。
常见误区
- 把哈希称为加密:哈希没有保密性。
- 用 AES-ECB 或复用 GCM IV:会泄露模式或破坏认证安全。
- 把密钥放进 localStorage:XSS 或同源脚本可直接读取。
- 只加密前端:服务端、备份和日志仍可能保存明文。
- 只验证签名不验证密钥绑定:攻击者可能替换成自己的公钥。
高风险密码学方案优先采用经过审查的协议和库,不要自行设计算法组合。Web Crypto 适合明确、可验证的原语调用;密钥生成、分发、恢复、撤销和服务端权限才是完整安全方案的主体。