Skip to content
crypto2026-07-255 分钟阅读

过去在浏览器中加密数据,意味着要引入一个庞大的 JavaScript 库,并祈祷它的实现是正确的。如今,每个现代浏览器都内置了 Web Crypto API——一个原生、高性能、经过审计的密码学接口,完全在客户端运行。本指南带你走完使用 Web Crypto API 进行 AES 加密的全过程,重点讲解 AES-256-GCM(你应该使用的模式)和 PBKDF2 密钥派生的实战用法。

本文所有代码都在浏览器本地运行。数据不会离开你的机器,不涉及任何服务端,也不需要第三方库。

AES 模式:为什么选 GCM 而不是 CBC

AES(Advanced Encryption Standard)是一种对称分组密码——加密和解密用同一把密钥。但 AES 本身只能加密固定 128 位的块。"工作模式"定义了这些块如何串联起来处理任意长度的数据。你最常遇到的两种模式:

AES-CBC(Cipher Block Chaining)

  • 每个块在加密前先与前一个密文块做 XOR。
  • 第一个块需要初始化向量(IV)。
  • 提供机密性,但不提供完整性。 攻击者可以修改密文而不被发现(填充预言攻击、比特翻转攻击)。
  • 要获得完整性,必须额外加 HMAC(Encrypt-then-MAC),而正确实现这一点很容易出错。

AES-GCM(Galois/Counter Mode)

  • 在一次操作中同时完成加密和认证。
  • 产生密文 + 认证标签(通常是 128 位)。
  • 同时提供机密性和完整性。 密文或关联数据的任何篡改都会导致解密失败。
  • 不需要填充(内部以流密码方式工作)。
  • 硬件加速更快(现代 CPU 有专门加速 GCM 的 AES-NI + CLMUL 指令)。

建议很明确:除非有明确的 CBC 遗留需求,否则一律使用 AES-256-GCM。 GCM 开箱即用提供认证加密,消除了整整一类实现 bug。

AES-256-GCM 概览:
- 密钥大小:256 位(32 字节)
- IV/Nonce:96 位(12 字节)——每次加密必须唯一,同一密钥下绝不能复用
- 认证标签:128 位(16 字节)——自动追加到密文后
- 输出:密文(长度与明文相同)+ 16 字节标签

完整的 AES-256-GCM Web Crypto API 示例

下面是一套完整可运行的加解密实现,使用 AES-256-GCM,并通过 PBKDF2 从密码派生密钥:

用 PBKDF2 派生密钥

你很少会手头就有一把现成的 256 位密钥。通常起点是一个密码或口令。PBKDF2(Password-Based Key Derivation Function 2)通过对 HMAC-SHA-256 进行大量迭代,把密码"拉伸"成一把密码学密钥:

async function deriveKey(password, salt) {
  // 把密码作为原始密钥材料导入
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(password),
    'PBKDF2',
    false,
    ['deriveKey']
  );

  // 用 PBKDF2 派生 AES-256-GCM 密钥
  return crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: salt,
      iterations: 600000,  // OWASP 2023 对 PBKDF2-SHA256 的建议值
      hash: 'SHA-256'
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

关键点:

  • 盐值(Salt):每次加密都必须随机且唯一。把它和密文一起存储(它不是秘密)。
  • 迭代次数:600,000 是 OWASP 目前对 PBKDF2-HMAC-SHA256 的建议值。迭代越多 = 暴力破解越慢。
  • 派生出的密钥不可导出(false),意味着 JavaScript 无法读回原始密钥字节——这是对抗基于 XSS 的密钥窃取的一道防线。

加密

async function encrypt(plaintext, password) {
  // 生成随机盐值(16 字节)和 IV(12 字节)
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const iv = crypto.getRandomValues(new Uint8Array(12));

  // 从密码派生密钥
  const key = await deriveKey(password, salt);

  // 加密
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv: iv },
    key,
    new TextEncoder().encode(plaintext)
  );

  // 打包:盐值(16)+ IV(12)+ 密文
  // 盐值和 IV 解密时需要,但不是秘密
  const packed = new Uint8Array(salt.length + iv.length + ciphertext.byteLength);
  packed.set(salt, 0);
  packed.set(iv, salt.length);
  packed.set(new Uint8Array(ciphertext), salt.length + iv.length);

  // 返回 Base64 格式,方便存储/传输
  return btoa(String.fromCharCode(...packed));
}

解密

async function decrypt(packed64, password) {
  // 从 Base64 解包
  const packed = Uint8Array.from(atob(packed64), c => c.charCodeAt(0));

  // 提取盐值、IV 和密文
  const salt = packed.slice(0, 16);
  const iv = packed.slice(16, 28);
  const ciphertext = packed.slice(28);

  // 用密码 + 盐值派生出同一把密钥
  const key = await deriveKey(password, salt);

  // 解密(GCM 会自动校验认证标签)
  const plainBuffer = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: iv },
    key,
    ciphertext
  );

  return new TextDecoder().decode(plainBuffer);
}

用法

const encrypted = await encrypt('API secret: sk-abc123...', 'my-strong-passphrase');
console.log(encrypted);
// "dGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIHN0cmluZy4uLg=="

const decrypted = await decrypt(encrypted, 'my-strong-passphrase');
console.log(decrypted);
// "API secret: sk-abc123..."

// 密码错误会抛异常(GCM 认证标签校验失败)
try {
  await decrypt(encrypted, 'wrong-password');
} catch (e) {
  console.error('Decryption failed: wrong password or tampered data');
}

关键实现细节

绝不在同一密钥下复用 IV

如果你用同一对(密钥, IV)加密两条消息,GCM 的安全性会彻底崩溃。看到两个用相同 IV 加密的密文后,攻击者可以对它们做 XOR 来还原两条明文的信息,并伪造合法密文。

// 每次加密都必须生成全新的 IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// 绝不要:const iv = new Uint8Array(12); // 全零 = 灾难性复用

用 crypto.getRandomValues,不要用 Math.random

Math.random() 不是密码学安全的——它的输出是可预测的。在密码学场景中,盐值、IV 和任何随机值都必须用 crypto.getRandomValues() 生成。

关联数据(AAD)

GCM 支持可选的附加认证数据(Additional Authenticated Data)——这部分数据会被认证(受完整性保护)但不加密。适用于把密文绑定到某个上下文:

const ciphertext = await crypto.subtle.encrypt(
  {
    name: 'AES-GCM',
    iv: iv,
    additionalData: new TextEncoder().encode('user-id:12345')  // AAD
  },
  key,
  plaintext
);
// 解密时必须提供相同的 AAD,否则失败

性能

Web Crypto 操作是异步的,在原生代码中执行。供参考,在一台现代笔记本上:

  • AES-256-GCM 加密:约 2 GB/s(通过 AES-NI 硬件加速)
  • PBKDF2 60 万次迭代:约 200-400ms(故意很慢——这正是目的)

密钥派生按设计就是瓶颈。如果你要用同一密码加密多条消息,派生一次密钥然后复用:

const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKey(password, salt);  // 慢:约 300ms

// 快:用同一把派生密钥加密多条消息
const msg1 = await encryptWithKey(key, 'message one');   // <1ms
const msg2 = await encryptWithKey(key, 'message two');   // <1ms

什么时候该用浏览器端加密

浏览器端 AES 加密适用于:

  • 敏感数据不应离开客户端。 先加密再发送,意味着服务端只会看到密文。即使服务端被攻破,数据也是受保护的。
  • 本地存储加密。 在存入 localStorage/IndexedDB 之前,先加密 API 密钥、token 或笔记,可以防范基于 XSS 的数据窃取(攻击者拿到的是密文,不是明文)。
  • 端到端加密。 双方派生共享密钥并在客户端加密消息。服务端只转发密文,永远不持有密钥。
  • 零知识架构。 服务提供方从根本上无法读取你的数据,因为加解密只发生在你的浏览器里。

不适合的场景:

  • 服务端静态数据加密(用云厂商的加密或 KMS)。
  • TLS/HTTPS(那是传输层加密,由浏览器自动处理)。
  • 服务端需要读取/处理数据的场景(如果服务端需要明文访问,就不能加密)。

想不写代码就体验 AES 加密?DevToolkit Pro 的 AES 加解密 工具完全使用 Web Crypto API 在浏览器中运行 AES-256-GCM。你的明文和密码永远不会离开你的机器——加密在本地用原生代码完成,没有服务端往返。它是快速加密敏感字符串、验证密文或理解输出格式的好帮手,方便你在自己的应用中落地实现。


ad