很多人第一次接触 Web Crypto API 时,会把它当成浏览器里的“密码学工具箱”,觉得只要调几个函数就能解决前端加密问题。实际上,这套 API 的边界、算法选择和密钥管理方式,都比看起来要复杂。本文会从浏览器端加密、签名和密钥管理的真实场景出发,讲清楚 Web Crypto API 能做什么、不能做什么,以及落地时容易踩的坑。

先说一个很常见的场景:一个企业内部系统需要把一批敏感数据从前端直接加密后再传给后端,后端只保存密文。需求听起来很简单,但真正动手时会发现,生成密钥、保存密钥、每次加密用不同的 IV、以及后续的密钥轮换,每个环节都能让方案翻车。Web Crypto API 提供了一套标准化的接口,但标准只是起点,工程决策才是关键。
Web Crypto API 到底解决什么问题
Web Crypto API 是浏览器原生提供的密码学接口,通过 window.crypto.subtle 暴露主要功能。它支持对称加密、非对称加密、摘要、签名、密钥派生和随机数生成等操作。和先前的 JavaScript 加密库相比,它的优势在于底层实现由浏览器提供商完成,算法经过硬件和系统级优化,并且密钥可以通过 CryptoKey 对象以不可提取的方式绑定在浏览器环境内。
这个 API 解决的核心问题是:在没有原生代码的情况下,用 JavaScript 做相对可靠的安全操作。但要注意,它并不解决所有安全问题。前端代码始终暴露在用户环境中,任何密钥只要能被 JS 读取,就可能在内存中被窃取。所以在设计时,要明确威胁模型。
加密和解密:AES-GCM 是默认选择
Web Crypto API 支持的对称加密算法里,最值得优先使用的是 AES-GCM。GCM 在加密的同时提供认证,一旦密文被篡改,解密时会直接失败。这一点在传输敏感数据时非常重要。很多旧教程还在使用 AES-CBC,然后单独处理 HMAC 或者干脆不做完整性校验,这其实是很危险的。
下面是一个使用 AES-GCM 加密和解密的基本例子。生成一个 256 位密钥,随机生成 12 字节的 IV,然后加密。解密时使用同一个密钥和 IV,密文中需要把 IV 一起传过去。
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode('需要保护的数据');
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
plaintext
);
// 把 iv 和 ciphertext 一起发送到服务端
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv);
combined.set(new Uint8Array(ciphertext), iv.length);
解密时从 combined 中切出前 12 字节作为 IV,剩余部分作为密文。
const ivFromData = combined.slice(0, 12);
const ciphertextFromData = combined.slice(12);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivFromData },
key,
ciphertextFromData
);
const text = new TextDecoder().decode(decrypted);
这里的密钥是可提取的(generateKey 的第二个参数为 true),因为我们要演示存储或导出。真实系统建议尽量设置成不可提取,让密钥只能用于运算,不能被读取。
有一个容易被忽略的细节:AES-GCM 的 IV 绝对不能重复。如果同一个密钥下 IV 重复使用,攻击者可以恢复出密钥流,从而破解密文。每次加密都应该生成新的随机 IV,这是设计上必须遵守的纪律。
签名与验签:用私钥签名,用公钥验证
签名在浏览器端的一个典型用途是防篡改,比如前端生成某个操作请求后,用私钥对其签名,服务端持有对应的公钥进行验证。Web Crypto API 支持的签名算法包括 RSA-PSS、ECDSA 等。ECDSA 和常见后端的集成更直观,但要注意它只做签名,不做加密。
生成 ECDSA 密钥对很简单:
const keyPair = await crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256'
},
true,
['sign', 'verify']
);
const data = new TextEncoder().encode('需要签名的内容');
const signature = await crypto.subtle.sign(
{
name: 'ECDSA',
hash: 'SHA-256'
},
keyPair.privateKey,
data
);
验证时使用公钥,同时必须指定同一个 hash 算法。很多人在这里犯错,签名时用了 SHA-256,验证时却忘记指定,导致验证失败。虽然 API 文档里写了,但实际代码里确实容易漏掉。
签名和验证的场景里,还需要考虑签名数据的规范化。比如对一段 JSON 进行签名,如果前后字段顺序不一致,同一逻辑内容签出来的结果会完全不同。所以在设计签名时要先约定一个统一的序列化方式,比如把对象按 key 排序后转成字符串。
密钥管理:CryptoKey 和不可提取
Web Crypto API 最强大的地方之一,就是系统性的密钥管理。每次生成密钥,返回的是一个 CryptoKey 对象,而不是一段可以直接看到内容的二进制数据。你可以控制这个密钥是否可提取。如果设置为不可提取,JavaScript 代码就无法拿到原始密钥材料,只能通过 crypto.subtle 操作这个密钥。
这个设计在浏览器里非常有用。即使页面被注入了一段恶意脚本,攻击者也无法直接导出私钥,只能尝试在当前上下文里用它签名。当然,如果攻击者能完全控制页面,他可以直接调用 API 做任何操作,所以这不是完整的防护,但确实能降低因日志、监控或调试工具导致的意外泄露风险。
对于需要持久化的密钥,可以考虑使用 IndexedDB 保存 CryptoKey 对象。CryptoKey 对象可以被结构化克隆,因此能直接存入 IndexedDB。这样做的好处是,密钥可以跨页面会话保留,不需要每次都重新生成。
但要注意,IndexedDB 里保存的 CryptoKey 如果设置为可提取,那么拿到数据库读权限的脚本就能导出密钥;如果设置为不可提取,则只能在同一源(origin)内使用。这个取舍没有绝对好坏,取决于你的威胁模型。
将密钥导入导出到后端系统时,通常需要标准化格式。Web Crypto API 支持 RAW、PKCS#8、SPKI 和 JWK 等格式,但导出和导入时都必须像这样指定算法和用途:
const exported = await crypto.subtle.exportKey('jwk', key);
console.log(exported);
const imported = await crypto.subtle.importKey(
'jwk',
exported,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
);
这里有一个常见的误区:很多人会把 exportKey 的结果直接 JSON.stringify 之后存到本地,以为这样就完成了密钥备份。如果密钥本身是加密密钥,且没有额外的保护层,那么这次导出等于把安全边界全部打破。正确的做法是通过服务端或独立密钥管理系统进行封装,导出的密钥也要通过受控通道传输。
浏览器端加密的常见误区
- 误区一:把 Web Crypto API 当作“端到端加密”的万能方案。如果页面通过 HTTPS 传输密码到服务端,再由服务端加密,这并不属于端到端加密。真正的端到端加密要求服务端无法接触到明文或私钥,这在纯浏览器 JS 应用里很难做到,因为你无法保证服务端下发的代码不包含后门。
- 误区二:使用过时的算法组合。比如仍在使用 SHA-1 做摘要,或者在支持 AES-GCM 的浏览器里继续使用 CBC。CBC 如果没有配合正确的 MAC,非常容易受到填充预言攻击。GCM 作为 AEAD 算法,能同时保证机密性和完整性,应该是默认选项。
- 误区三:忘记考虑性能开销。非对称操作在移动设备上可能消耗数百毫秒,如果每次请求都生成密钥对,体验就很差。合理的方式是复用密钥,仅在需要时轮换。
- 误区四:把密钥直接硬编码在代码里。导入 API 虽然方便,但把固定密钥写进前端 bundle 等于没有任何保护,因为任何用户都可以在控制台里读取密钥、调用加密函数。
不同方案的对比:Web Crypto API 与第三方库
很多团队会考虑是否直接使用第三方加密库,比如 OpenPGP.js 或者本地库封装。它们各有适用场景,下面从几个维度对比一下。
| 方案 | 适用场景 | 安全性 | 集成成本 | 密钥管理 |
|---|---|---|---|---|
| Web Crypto API | 浏览器内原生操作、与后端做 JWT 或加密交换 | 高(取决于算法和参数) | 中,需要自己封装细节 | CryptoKey + IndexedDB |
| Node.js crypto 模块 | 服务端签名、加密、哈希 | 高 | 低,面向服务器 | 与文件系统或 KMS 集成 |
| OpenPGP.js | 需要 PGP 兼容格式的场景,如邮件加密 | 高,但复杂度高 | 高,API 偏重 | 支持私钥文件,但需要额外密码保护 |
| 自研纯 JS 加密算法 | 不推荐 | 极低 | 极高 | 无完善体系 |
从表里可以看出,如果只在浏览器端做对称加密或签名验证,Web Crypto API 是性价比最好的选择。如果需要跟外部系统交换 PGP 格式数据,才需要引入 OpenPGP.js。
在真实项目中落地 Web Crypto API
落地过程中,建议先从一个使用场景开始,而不是把所有加密逻辑一次性铺开。比如先做一个“前端加密上传”的流程:前端生成 AES 密钥,用公钥加密这个 AES 密钥,然后文件用 AES-GCM 加密后上传,后端用自己的私钥解开临时 AES 密钥,再解密文件。这种方式也叫混合加密(Hybrid Encryption),可以避免直接使用非对称加密大文件带来的性能问题。
另一个典型的场景是前端加密本地数据。比如浏览器插件需要在本地保存用户的 API token,可以使用 Web Crypto API 生成密钥,再通过某种方式加密存储。需要注意的是,密钥本身如果保存在同一处,安全性就有限,但至少能防止明文直接躺在数据库或 localStorage 里。最好结合用户口令派生出密钥,例如使用 PBKDF2 或 scrypt(Web Crypto API 提供 PBKDF2)。
实际项目中还要考虑密钥轮换计划。建议给每个密钥加上版本号,加密数据时带上密钥版本,服务端在解密时根据版本选择对应的密钥。这样即使密钥泄露,也可以只影响该版本的数据。
在使用 PBKDF2 时,需要注意迭代次数不要太低。OWASP 建议的迭代次数是 600,000 次,但具体取决于浏览器性能。Web Crypto API 的 deriveKey 会按顺序处理,过高的次数会让页面卡顿,需要权衡。
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode('用户提供的口令'),
'PBKDF2',
false,
['deriveKey']
);
const derivedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: new TextEncoder().encode('固定的盐值'),
iterations: 600000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
这里的盐值应该每个用户单独生成,不要写死。
总结:Web Crypto API 的价值与边界
Web Crypto API 让浏览器端的加密、签名和密钥管理有了统一的、由浏览器厂商维护的实现。它减少了引入第三方库的依赖,也提供了不可提取的密钥机制。但它的存在并不等于安全。真正的安全来自对算法、密钥生命周期的理解,以及对威胁模型的认真分析。
如果你刚开始使用,建议先掌握 AES-GCM 加密、ECDSA 签名、CryptoKey 的导入导出和 PBKDF2 密钥派生这几个模块。把它们正确地组合起来,就能覆盖大多数前端安全需求。至于更复杂的场景,比如多方密钥协商或多设备同步,往往需要后端参与,这部分就不是 Web Crypto API 单独能解决的了。
浏览器安全没有银弹,但 Web Crypto API 至少给我们提供了一套正确的工具。关键就看你怎么用了。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/914/