Skip to content
扫码开始移动端阅读

Web Crypto API:浏览器里也能加密了

4820
24.1分钟

提起前端加密,很多人的第一反应还是:装一个 npm 包,比如 crypto-js。再往下聊,经常又会冒出另一个说法:前端代码都是公开的,所以加密应该是后端的事情。

这个说法只对了一半。

前端确实不能把“秘密”硬藏在 JS 代码里。你把 AES 密钥、私钥、固定 salt 写进源码,本质上就是把钥匙贴在门上。但是这不等于前端不参与加密。现代 Web 里,前端经常要做随机数生成、哈希、签名验签、用服务端公钥加密数据、生成临时会话密钥、解密本地私有数据、校验文件完整性。

所以问题不是“加密是不是后端的事情”,而是:哪个密钥在谁手里,哪一步应该由谁来做。

这篇就把 Web Crypto API 讲清楚一点。它不是一个第三方库,而是浏览器原生提供的加密能力,入口通常是 cryptocrypto.subtle

先把前后端加密流程说清楚

最容易混的地方,是对称加密、非对称加密、签名这三个东西。

对称加密只有一把密钥。加密和解密都用它,比如 AES。它速度快,适合加密正文、文件、接口 payload。但是密钥怎么安全地给对方,是它最大的麻烦。

非对称加密有两把钥匙:公钥和私钥。公钥可以公开,私钥必须保密。常见规则是:

  • 用公钥加密,只有对应私钥能解密。
  • 用私钥签名,任何拿到公钥的人都能验签。

这两句话非常关键。

比如登录页要把一段敏感信息发给后端,常见流程不是“前端随便加密一下”,而是:

  1. 后端生成一对密钥,私钥留在后端,公钥发给前端。
  2. 前端用公钥加密数据,或者生成一个 AES 密钥加密正文,再用公钥加密这个 AES 密钥。
  3. 后端用私钥解开密文或 AES 密钥。
  4. 中间人即使看到请求,也没有后端私钥,不能直接解开。

反过来,如果前端自己有一对密钥,也可以把公钥交给后端,私钥留在浏览器里。后端发给这个用户的内容可以用用户公钥加密,只有这个浏览器里的私钥能解密。端到端加密、离线笔记、钱包类产品经常会碰到这种设计。

再比如签名,它不是为了隐藏内容,而是为了证明“这段内容确实由某个私钥持有者发出,并且中途没有被改”。这时是私钥签名,公钥验签。

所以,前端能不能加密,不取决于“代码公开不公开”,取决于密钥是否真的由正确的一方持有。不要把长期密钥写死在前端源码里,但前端完全可以使用服务端公钥、用户本地私钥、临时会话密钥来完成加密任务。

Web Crypto API 有哪些入口

浏览器里的 window.crypto 主要分两层。

第一层是 crypto.getRandomValues(),用来生成密码学安全的随机数。IV、nonce、salt、随机 token 这类东西都应该从这里来,不要用 Math.random()

第二层是 crypto.subtle,它的类型叫 SubtleCrypto。常用方法如下:

方法用途常见场景
generateKey()生成密钥或密钥对生成 AES 密钥、RSA/ECDSA/ECDH 密钥对
importKey()导入外部密钥导入后端给的公钥、导入本地保存的 JWK
exportKey()导出密钥导出公钥、备份可导出的本地密钥
encrypt()加密AES-GCM、RSA-OAEP
decrypt()解密AES-GCM、RSA-OAEP
sign()签名HMAC、RSA-PSS、ECDSA、Ed25519
verify()验签校验签名是否可信
digest()哈希SHA-256 文件指纹、内容摘要
deriveKey()派生密钥PBKDF2/HKDF 派生 AES 密钥
deriveBits()派生二进制数据ECDH 共享秘密、KDF 输出
wrapKey()包装密钥用一个密钥加密另一个密钥
unwrapKey()解包密钥还原被包装的密钥

你会发现它不是只有“加密解密”两个 API,而是一整套密钥生命周期 API。

CryptoKey 也要看懂

Web Crypto API 里很多方法返回的不是字符串,而是 CryptoKey。它不是普通 JS 对象,不一定能直接打印出密钥内容。

一个 CryptoKey 主要有几个属性:

属性含义
typesecretpublicprivate,分别表示对称密钥、公钥、私钥
extractable是否允许 exportKey() 导出
algorithm密钥绑定的算法信息,比如 AES-GCMRSA-OAEP
usages这把密钥能做什么,比如 encryptdecryptsignverify

生成密钥时传入的第二个参数也很重要:

javascript
const key = await crypto.subtle.generateKey(
  { name: 'AES-GCM', length: 256 },
  false,
  ['encrypt', 'decrypt']
);

这里的 false 表示密钥不可导出。这样即使 JS 代码拿到了 CryptoKey 对象,也不能通过 exportKey() 把原始密钥吐出来。它不是万能保险,但比到处传字符串密钥更靠谱。

如果你确实要持久化密钥,比如存在 IndexedDB,就必须认真决定 extractable、存储位置、用户登出时是否清理、是否需要用户密码派生出来的密钥再保护它。

支持哪些算法

Web Crypto API 支持的是一组标准算法,不是想填什么算法名都行。常见能力可以按用途看:

用途常见算法说明
哈希SHA-1SHA-256SHA-384SHA-512新项目优先用 SHA-256 及以上;SHA-1 不适合安全用途
对称加密AES-GCMAES-CBCAES-CTRAES-KW业务加密优先考虑 AES-GCM,因为它同时提供认证
非对称加密RSA-OAEP适合加密小数据或包装 AES 密钥,不适合直接加密大文件
签名验签RSA-PSSRSASSA-PKCS1-v1_5ECDSAHMACEd25519新设计里 RSA-PSS 通常比老的 PKCS#1 v1.5 更推荐
密钥派生PBKDF2HKDFECDHX25519PBKDF2 常用于从密码派生密钥;ECDH/X25519 用于协商共享秘密

具体浏览器支持会跟算法有关。比如 Web Cryptography 这个大 API 在现代浏览器里已经比较普遍,但某些新算法不一定所有浏览器都齐。写生产代码时,不要只判断 crypto.subtle 存在,还要对你使用的算法做降级或错误提示。

AES-GCM:前端加密正文最常见

先看对称加密。AES-GCM 是现在前端最常用的选择之一,因为它不只加密,还会校验密文有没有被篡改。

javascript
const encoder = new TextEncoder();
const decoder = new TextDecoder();

// 生成一把 256 位 AES 密钥,false 表示不允许把原始密钥导出。
const key = await crypto.subtle.generateKey(
  { name: 'AES-GCM', length: 256 },
  false,
  ['encrypt', 'decrypt']
);

// AES-GCM 推荐 12 字节 IV;IV 不用保密,但同一把 key 下不能重复。
const iv = crypto.getRandomValues(new Uint8Array(12));
const plainText = encoder.encode('Hello, Web Crypto');

// encrypt 返回 ArrayBuffer,接口传输前通常要再转成 Base64 或二进制。
const cipherBuffer = await crypto.subtle.encrypt(
  { name: 'AES-GCM', iv },
  key,
  plainText
);

const decryptedBuffer = await crypto.subtle.decrypt(
  { name: 'AES-GCM', iv },
  key,
  cipherBuffer
);

console.log(decoder.decode(decryptedBuffer));

几个点要记住:

  • AES-GCM 的 iv 推荐 12 字节。
  • 同一把 AES-GCM 密钥下不要复用 iv
  • iv 不需要保密,通常和密文一起保存或传输。
  • 真正要保密的是 key
  • encrypt() 返回的是 ArrayBuffer,要自己转 Base64、hex 或二进制上传。

一个简单的 Base64 转换可以这样写:

javascript
function arrayBufferToBase64(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = '';

  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }

  return btoa(binary);
}

RSA-OAEP:前端用公钥加密,后端用私钥解密

非对称加密最典型的前后端分工,是前端拿公钥,后端拿私钥。

下面先演示浏览器里生成一对 RSA-OAEP 密钥。真实业务里,通常是后端生成密钥对,把公钥通过接口或页面配置给前端。

javascript
// publicKey 可以给前端加密使用,privateKey 必须留在解密方手里。
const { publicKey, privateKey } = await crypto.subtle.generateKey(
  {
    name: 'RSA-OAEP',
    modulusLength: 2048,
    publicExponent: new Uint8Array([1, 0, 1]),
    hash: 'SHA-256',
  },
  true,
  ['encrypt', 'decrypt']
);

const data = new TextEncoder().encode('只想让私钥持有者看到');

// 用公钥加密,拿到私钥的人才能解密。
const encrypted = await crypto.subtle.encrypt(
  { name: 'RSA-OAEP' },
  publicKey,
  data
);

// 用私钥解密。真实前后端流程里,这一步通常发生在后端。
const decrypted = await crypto.subtle.decrypt(
  { name: 'RSA-OAEP' },
  privateKey,
  encrypted
);

console.log(new TextDecoder().decode(decrypted));

但是这里要补一个很重要的常识:RSA 不适合直接加密大文本或大文件。 它能加密的数据长度受密钥长度和填充方案限制。实际项目里更常见的是混合加密:

  1. 前端生成一个随机 AES-GCM 密钥。
  2. 前端用 AES-GCM 加密真正的业务数据。
  3. 前端用后端 RSA 公钥加密这个 AES 密钥。
  4. 请求里带上 AES 密文、AES 的 iv、被 RSA 加密后的 AES 密钥。
  5. 后端用 RSA 私钥解开 AES 密钥,再用 AES 解正文。

这样既利用了 AES 的速度,也解决了 AES 密钥传输问题。

importKey:导入后端给你的公钥

真实项目里,公钥经常是后端给你的。Web Crypto 支持多种密钥格式,常见有:

  • jwk:JSON Web Key,对前端最友好。
  • spki:常用于导入公钥。
  • pkcs8:常用于导入私钥。
  • raw:常用于 AES/HMAC 这类原始密钥。

如果后端给的是 JWK 公钥,可以这样导入:

javascript
// 这里的 n 只是占位;真实 RSA 公钥的 n 会是一段很长的 Base64URL 字符串。
const publicJwk = {
  kty: 'RSA',
  e: 'AQAB',
  n: '这里是后端给你的 modulus,实际会很长',
  alg: 'RSA-OAEP-256',
  ext: true,
};

// false 表示导入后不允许再导出;['encrypt'] 限制这把公钥只能用于加密。
const publicKey = await crypto.subtle.importKey(
  'jwk',
  publicJwk,
  { name: 'RSA-OAEP', hash: 'SHA-256' },
  false,
  ['encrypt']
);

如果后端给的是 PEM,你一般要先去掉 -----BEGIN PUBLIC KEY----- 这些头尾,再 Base64 解码成 ArrayBuffer,然后按 spki 导入。

javascript
function pemToArrayBuffer(pem) {
  // Web Crypto 不直接吃 PEM 文本,要先去掉头尾并解码成二进制。
  const base64 = pem
    .replace(/-----BEGIN PUBLIC KEY-----/g, '')
    .replace(/-----END PUBLIC KEY-----/g, '')
    .replace(/\s/g, '');
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);

  for (let i = 0; i < binary.length; i += 1) {
    bytes[i] = binary.charCodeAt(i);
  }

  return bytes.buffer;
}

const publicKey = await crypto.subtle.importKey(
  'spki',
  pemToArrayBuffer(publicPem),
  { name: 'RSA-OAEP', hash: 'SHA-256' },
  false,
  ['encrypt']
);

exportKey:哪些密钥可以导出

只有 extractable: true 的密钥才能导出。

javascript
const publicJwk = await crypto.subtle.exportKey('jwk', publicKey);
console.log(publicJwk);

公钥导出通常没问题,因为公钥本来就可以公开。私钥、AES 密钥是否导出要谨慎。能不导出就不导出;如果必须导出,也要用用户密码派生密钥或平台能力再保护一层。

签名:私钥签名,公钥验签

签名不是加密。签名后的内容仍然可能是明文,它解决的是身份和完整性问题。

javascript
// privateKey 用来签名,publicKey 用来验签。
const { publicKey, privateKey } = await crypto.subtle.generateKey(
  {
    name: 'RSA-PSS',
    modulusLength: 2048,
    publicExponent: new Uint8Array([1, 0, 1]),
    hash: 'SHA-256',
  },
  false,
  ['sign', 'verify']
);

const data = new TextEncoder().encode('这段内容不能被偷偷篡改');

// 私钥签名:证明这段数据来自私钥持有者。
const signature = await crypto.subtle.sign(
  { name: 'RSA-PSS', saltLength: 32 },
  privateKey,
  data
);

// 公钥验签:校验签名和原文是否匹配。
const ok = await crypto.subtle.verify(
  { name: 'RSA-PSS', saltLength: 32 },
  publicKey,
  signature,
  data
);

console.log(ok);

如果你把 data 改一个字,验签就会失败。

前端常见用法包括:

  • 校验后端下发配置是否被篡改。
  • 校验下载文件和签名是否匹配。
  • 钱包或身份类产品里,由用户私钥对交易、声明、挑战字符串签名。

digest:哈希不是密码加密

哈希是单向摘要,不是加密。它不能被“解密”回原文。

javascript
const data = new TextEncoder().encode('Hello');
const hash = await crypto.subtle.digest('SHA-256', data);

const hex = Array.from(new Uint8Array(hash))
  .map((byte) => byte.toString(16).padStart(2, '0'))
  .join('');

console.log(hex);

它适合做文件指纹、内容校验、缓存 key、签名前摘要。

但别直接把 SHA-256(password) 当成密码存储方案。密码需要抗暴力破解,应该用专门的 KDF,比如 PBKDF2。更现代的服务端密码存储还会用 Argon2、bcrypt、scrypt 这类方案;浏览器原生 Web Crypto 目前常见可用的是 PBKDF2。

一个 PBKDF2 派生 AES 密钥的例子:

javascript
// 先把用户输入的密码导入成 PBKDF2 的基础密钥。
const passwordKey = await crypto.subtle.importKey(
  'raw',
  new TextEncoder().encode('user password'),
  'PBKDF2',
  false,
  ['deriveKey']
);

// salt 不需要保密,但每个用户、每份数据都应该不同。
const salt = crypto.getRandomValues(new Uint8Array(16));

// 从密码派生出真正用于 AES-GCM 的密钥。
const aesKey = await crypto.subtle.deriveKey(
  {
    name: 'PBKDF2',
    salt,
    iterations: 210000,
    hash: 'SHA-256',
  },
  passwordKey,
  { name: 'AES-GCM', length: 256 },
  false,
  ['encrypt', 'decrypt']
);

这里的 salt 也不需要保密,但每个用户、每份数据都应该尽量不同。迭代次数要根据设备性能和安全要求调整,不是越大越无脑。

和 crypto-js 到底有什么区别

crypto-js 是纯 JavaScript 库,Web Crypto API 是浏览器原生能力。这两者差别不只是“一个要安装,一个不用安装”。

对比项Web Crypto APIcrypto-js
来源浏览器原生 API第三方 JS 包
包体积不增加业务 bundle需要下载 JS
性能通常走浏览器底层实现,性能更好纯 JS 实现,通常更慢
密钥形态CryptoKey,可限制不可导出和用途多数是字符串或 WordArray,开发者自己管理
API 风格Promise + ArrayBuffer + TypedArray同步 API + WordArray/字符串
算法标准算法集合,受浏览器支持影响库里实现了不少传统算法
运行环境浏览器安全上下文、Worker;Node 也有 Web Crypto 实现浏览器和 Node 都能跑
适合场景现代浏览器里的加密、签名、哈希、密钥派生兼容旧环境、已有项目迁移、需要库中特定算法

我实际查了一下 crypto-js@4.2.0 的包:npm registry 标的解包大小是 486,947 bytes。包里的完整 crypto-js.js219,092 bytes,gzip 后约 39,619 bytes。如果只看 CDN 上的 crypto-js.min.js,约 60,819 bytes,gzip 后约 23,407 bytes

如果只按模块引入,体积会小很多。比如包里的 aes.js 约 8.6 KB,sha256.js 约 5.5 KB,但真实打包还要带上 corecipher-core、编码、填充、模式等依赖,不能只看单文件大小。

跑一个浏览器性能对比

测试环境先说清楚:

项目信息
设备Apple Silicon Mac
系统macOS
浏览器Chrome for Testing 148,也就是 HeadlessChrome 148
页面环境localhost 安全上下文
输入大小69,632 bytes 文本
测试方式每项先预热,再循环多次取平均值

先看哈希。这个是同一个 SHA-256 算法,比较更直接:

能力Web CryptoCryptoJS差距
SHA-2560.0280 ms/次0.8295 ms/次CryptoJS 约慢 29.6 倍

再看 AES。这里要注意,Web Crypto 测的是 AES-GCM,CryptoJS raw key 测的是 AES-CBC,算法模式不完全相同,所以只能看大致性能量级,不能当成严格同算法跑分。

能力Web CryptoCryptoJS差距
AES 加密AES-GCM 0.0250 ms/次passphrase 2.3413 ms/次CryptoJS 约慢 93.7 倍
AES 解密AES-GCM 0.0237 ms/次passphrase 2.1525 ms/次CryptoJS 约慢 90.8 倍
AES 加密AES-GCM 0.0250 ms/次AES-CBC raw key 2.0750 ms/次CryptoJS 约慢 83.0 倍
AES 解密AES-GCM 0.0237 ms/次AES-CBC raw key 1.9137 ms/次CryptoJS 约慢 80.7 倍

这个测试不是严谨论文,只代表上面这套设备、浏览器版本、输入大小下的结果。

但结论还是很明显:现代浏览器里,原生 Web Crypto 通常比纯 JS 加密库快很多,而且不会额外增加 bundle 体积。

Can I Use 支持情况

Can I Use 里这个能力叫 Web Cryptography。截至我这次查询,Chrome、Edge、Firefox、Safari、iOS Safari 这些现代浏览器都已经支持。旧坑主要是:

  • IE 11 是旧版规范支持,不建议再按现代 Web Crypto 体验理解。
  • Safari 11 之前用过 crypto.webkitSubtle 前缀。
  • Edge 12 到 18 在 Worker 和 Service Worker 里有支持限制。

另外,MDN 也明确标了 Web Crypto API 是安全上下文能力。实际开发时你应该默认它需要 HTTPS;本地开发的 localhost 通常也算安全上下文。

判断可以这样写:

javascript
function canUseWebCrypto() {
  return Boolean(
    window.isSecureContext &&
      window.crypto &&
      window.crypto.subtle &&
      window.crypto.getRandomValues
  );
}

如果你的用户还在非常老的浏览器里,那就要准备降级方案。对现代站点来说,优先用 Web Crypto API 通常是更好的默认选择。

常见坑

第一,不要复用 AES-GCM 的 IV。 同一把 key 下复用 IV 是严重问题。IV 可以公开,但必须保证随机或唯一。

第二,不要把长期密钥写死在前端代码里。 写进源码的密钥不是密钥,只是字符串。

第三,不要混淆加密和签名。 公钥加密是为了让私钥持有者解密;私钥签名是为了让公钥持有者验证身份。

第四,不要直接用 RSA 加密大数据。 大数据用 AES,加密 AES key 再用 RSA-OAEP。

第五,注意编码转换。 Web Crypto 大量使用 ArrayBufferUint8ArrayTextEncoderTextDecoder。传接口前要明确是 Base64、hex 还是二进制。

第六,不要把 SHA-256 当密码存储。 哈希不是密码存储方案,至少要用 PBKDF2 这类 KDF,服务端存密码还应该考虑更专业的慢哈希方案。

写在最后

先给一张选择图:

可以被 Web Crypto API 替代的:

  • SHA-256SHA-384SHA-512 这类哈希。
  • AES-GCMAES-CBCAES-CTR 这类标准 AES 能力。
  • HMACRSA-PSSECDSA 这类签名和验签。
  • RSA-OAEP 这类公钥加密,尤其适合加密临时 AES 密钥。
  • PBKDF2HKDFECDH 这类密钥派生和密钥协商。
  • 安全随机数,直接用 crypto.getRandomValues()

不能完全替代的:

  • 需要兼容很老浏览器的项目。
  • 依赖 MD5RIPEMD160TripleDESRabbitRC4 这类 Web Crypto 不提供或不推荐的新旧算法。
  • 必须和旧系统保持 CryptoJS 默认格式兼容的项目,比如 passphrase 加密出来的 OpenSSL 风格字符串。
  • 想要同步 API、字符串 API,不想处理 ArrayBufferUint8ArrayCryptoKey 的简单脚本。

我的建议很简单:新项目优先用 Web Crypto API。只有遇到旧浏览器、旧算法、旧密文格式兼容这几类问题时,再引入 crypto-js

文档参考