使用 WebCrypto 在客户端加密数据

Web 功能: Web Cryptography

使用 Web Cryptography API (crypto.subtle),通过抗量子密钥封装机制 (KEM) 或传统密钥协商,在不受信任的网络上建立共享密钥,并在浏览器中使用带有关联数据的认证加密 (AEAD) 对敏感数据进行加密。如需在不加密的情况下对消息或工件进行签名和验证,请参阅使用 WebCrypto 对数据进行签名和验证。

端到端信封加密流程分为两个阶段:

  • 一次性接收方设置(第 1-2 步):接收方生成密钥对,将 CryptoKeyPair 保存在 IndexedDB 中以供未来会话使用,并导出公钥 ('raw-public') 以与发送方共享。
  • 每条消息加密(第 3-4 步):对于每个载荷,发送方导入接收方的公钥,调用 encapsulateKey() 生成新的单次共享密钥和密文封装,使用新的 12 字节 IV 加密载荷,并将 { capsule, iv, ciphertext } 发送给接收方。

1. 选择 KEM 算法并生成接收方密钥对

在 Diffie-Hellman 密钥协商 (deriveKey()) 中,双方会组合各自的密钥。而密钥封装机制 (KEM) 则不同,它使用接收方的公钥,通过一次 encapsulateKey() 调用同时生成随机共享密钥和密文封装。接收方使用 decapsulateKey() 从密文封装容器中恢复相同的共享密钥。

默认使用 'MLKEM768-X25519':它将后量子 ML-KEM(在 NIST FIPS 203 中标准化)与传统 'X25519' 相结合,因此只要其中一种算法有效,共享密钥就会保持安全。如果政策要求纯 FIPS 203 输出(不含混合组件),请选择独立 'ML-KEM-768';如果政策要求符合 NIST 类别 5 (CNSA 2.0) 标准,请选择 'ML-KEM-1024'。

算法名称 安全等级和应用场景 公钥 ('raw-public') 私有种子 ('raw-seed') 密文大小
'MLKEM768-X25519' 混合后量子 ('ML-KEM-768') + 经典 ('X25519');推荐的默认设置 1216 字节 32 个字节 1120 字节
'ML-KEM-768' NIST 类别 3(约 192 位安全性);独立后量子 KEM 1184 字节 64 字节 1088 字节
'ML-KEM-1024' NIST 类别 5(约 256 位安全性);最高安全裕度 1568 字节 64 字节 1568 字节
  • 传递 'MLKEM768-X25519'、'ML-KEM-768' 或 'ML-KEM-1024' 作为算法名称(别名(例如 'X-Wing' 或 'X25519MLKEM768')以及 'ML-KEM-512' 会抛出 NotSupportedError)。
  • 生成 KEM 密钥对时,请传递 extractable: false 和 ['encapsulateKey', 'decapsulateKey'](或 ['encapsulateBits', 'decapsulateBits'])。不可提取的 CryptoKey 和 CryptoKeyPair 对象可以通过结构化克隆 (objectStore.put(recipientKeyPair, 'recipient-kem-key')) 直接在 IndexedDB 中跨会话保留,而无需导出私钥字节:
const recipientKeyPair = await crypto.subtle.generateKey(
  'MLKEM768-X25519',
  false,
  ['encapsulateKey', 'decapsulateKey'],
);

2. 导出和导入密钥

现代 WebCrypto 算法('MLKEM768-X25519'、'ML-KEM-768'、'ML-KEM-1024' 和 'ChaCha20-Poly1305')会拒绝 importKey() 和 exportKey() 中采用旧版 'raw' 格式的密钥,并抛出 NotSupportedError。请始终指定显式密钥格式:

  • 'raw-public':公钥字节(1216、1184 或 1568 字节)。导入 KEM 公钥时,请传递 ['encapsulateKey'](或 ['encapsulateBits'])。
  • 'raw-seed':私钥种子字节('MLKEM768-X25519' 为 32 字节;'ML-KEM-768' 和 'ML-KEM-1024' 为 64 字节)。导入 KEM 私钥时传递 ['decapsulateKey'](或 ['decapsulateBits'])(将 ['encapsulateKey', 'decapsulateKey'] 传递给 importKey() 或 getPublicKey() 会抛出 SyntaxError)。'raw' 和 'raw-private' 都会在 KEM 私钥上抛出 NotSupportedError。请注意,'MLKEM768-X25519' 仅支持 'raw-public'、'raw-seed' 和 'jwk',不支持 'spki' 或 'pkcs8'。
  • 'raw-secret':对称密钥字节('ChaCha20-Poly1305',在 'AES-GCM' 和 'HKDF' 上接受)。

使用 'raw-public' 导出接收方的公钥,以便将公钥字节序列化(例如,通过 new Uint8Array(recipientPublicKeyBytes).toBase64() 序列化为 Base64 或十六进制)并与发送方共享:

// Export the recipient's public key bytes
const recipientPublicKeyBytes = await crypto.subtle.exportKey(
  'raw-public',
  recipientKeyPair.publicKey,
);

// Import the recipient's public key on the sender (usage must be ['encapsulateKey'])
const importedPublicKey = await crypto.subtle.importKey(
  'raw-public',
  recipientPublicKeyBytes,
  'MLKEM768-X25519',
  true,
  ['encapsulateKey'],
);

如果您仅持有私有 CryptoKey(即使具有 extractable: false),并且需要其对应的公共 CryptoKey,请调用 crypto.subtle.getPublicKey():

const publicKey = await crypto.subtle.getPublicKey(
  recipientKeyPair.privateKey,
  ['encapsulateKey'],
);

3. 封装和解封共享密钥

encapsulateKey() 和 decapsulateKey() 使用 sharedKeyAlgorithm 中指定的算法(采用 'raw-secret' 格式)将 32 字节(256 位)KEM 共享密钥导入 CryptoKey。直接传递 'ChaCha20-Poly1305' 或 'AES-GCM' 以生成 AEAD 加密密钥:

// Sender encapsulates a 256-bit AEAD key and produces a ciphertext capsule
const { sharedKey: senderKey, ciphertext: encapsulatedCiphertext } =
  await crypto.subtle.encapsulateKey(
    'MLKEM768-X25519',
    importedPublicKey,
    'ChaCha20-Poly1305', // Or 'AES-GCM'
    false,
    ['encrypt', 'decrypt'],
  );

// Recipient decapsulates the ciphertext capsule to recover the identical AEAD key
const recipientKey = await crypto.subtle.decapsulateKey(
  'MLKEM768-X25519',
  recipientKeyPair.privateKey,
  encapsulatedCiphertext,
  'ChaCha20-Poly1305',
  false,
  ['encrypt', 'decrypt'],
);

使用 'HKDF' 进行网域分离

如需将共享密钥绑定到特定协议上下文(info 和 salt),或从单个封装派生多个子密钥,请将 'HKDF' 作为 sharedKeyAlgorithm 传递,并使用 ['deriveKey'],然后调用 crypto.subtle.deriveKey():

const { sharedKey: hkdfBaseKey, ciphertext } = await crypto.subtle.encapsulateKey(
  'MLKEM768-X25519',
  importedPublicKey,
  'HKDF',
  false,
  ['deriveKey'],
);

const aeadKey = await crypto.subtle.deriveKey(
  {
    name: 'HKDF',
    hash: 'SHA-256',
    salt: new Uint8Array(32),
    info: new TextEncoder().encode('envelope-v1'),
  },
  hkdfBaseKey,
  { name: 'AES-GCM', length: 256 },
  false,
  ['encrypt', 'decrypt'],
);

4. 加密和解密载荷('ChaCha20-Poly1305' 和 'AES-GCM')

使用 AEAD 算法('ChaCha20-Poly1305' 或 'AES-GCM')加密载荷数据,每次加密操作都使用全新的加密随机 12 字节(96 位)初始化向量 (iv)。切勿重复使用 (iv, key) 对。如果设备没有专用的 AES 硬件加速,请选择 'ChaCha20-Poly1305',以在这些设备上实现一致的恒定时间性能;如果要与现有 WebCrypto 或 FIPS 140 部署进行互操作,请选择 'AES-GCM'。

  • 'ChaCha20-Poly1305':使用固定的 256 位(32 字节)密钥(generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt']) 接受字符串或不含 length 属性的 { name: 'ChaCha20-Poly1305' })。iv 必须正好为 12 字节,并且 128 位 Poly1305 身份验证标记会附加到密文。
  • 'AES-GCM':调用 generateKey() 或 deriveKey() 时传递 { name: 'AES-GCM', length: 256 },调用 encrypt() 和 decrypt() 时传递 12 字节的 iv。
  • additionalData (AAD):通过 additionalData 将未加密的元数据(例如记录 ID、接收者 ID 或协议版本)绑定到身份验证标记,这样一来,篡改密文或元数据都会导致 decrypt() 拒绝并返回 OperationError。
/**
 * Encrypts UTF-8 text with an AEAD CryptoKey ('ChaCha20-Poly1305' or 'AES-GCM').
 * @param {CryptoKey} key
 * @param {string} plaintext
 * @param {Uint8Array} [additionalData]
 */
export async function encryptMessage(key, plaintext, additionalData = new Uint8Array(0)) {
  // Always generate a fresh 12-byte (96-bit) IV per message
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(plaintext);

  const ciphertextBuffer = await crypto.subtle.encrypt(
    {
      name: key.algorithm.name,
      iv,
      additionalData,
    },
    key,
    encoded,
  );

  return { iv, ciphertext: new Uint8Array(ciphertextBuffer) };
}

/**
 * Decrypts and authenticates an AEAD payload. Throws OperationError if tampered.
 * @param {CryptoKey} key
 * @param {Uint8Array} iv
 * @param {Uint8Array} ciphertext
 * @param {Uint8Array} [additionalData]
 */
export async function decryptMessage(key, iv, ciphertext, additionalData = new Uint8Array(0)) {
  const plaintextBuffer = await crypto.subtle.decrypt(
    {
      name: key.algorithm.name,
      iv,
      additionalData,
    },
    key,
    ciphertext,
  );

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

后备策略

如果您的基准目标尚不支持 SubtleCrypto.supports()、encapsulateKey() 或 'ChaCha20-Poly1305',请使用 static SubtleCrypto.supports() 方法检测支持情况,并回退到临时静态迪菲-赫尔曼密钥协商('X25519' + 'HKDF' + 'AES-GCM',或在针对不支持 'X25519' 的浏览器时使用 'ECDH' 和 'P-256'):

  • 在 SubtleCrypto 构造函数(而非 crypto.subtle)上调用 SubtleCrypto.supports():globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305') 是一个返回布尔值的同步静态方法(crypto.subtle.supports 为 undefined)。请注意,SubtleCrypto.supports() 会验证特定于操作的参数:裸算法字符串适用于 'encapsulateKey'、'decapsulateKey'、'generateKey'、'importKey'、'exportKey' 和 'getPublicKey',而直接探测 'encrypt' 或 'decrypt' 则需要 iv(例如 SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) }))。
  • 后备方案中的密钥格式 ('raw'):虽然支持现代 WebCrypto 的浏览器也接受 'X25519' 和 'ECDH' 上的 'raw-public',但在针对旧版浏览器的后备路径中使用 'raw'。
  • 传统后备 ('X25519' + 'HKDF' + 'AES-GCM'):当 SubtleCrypto.supports() 不可用或返回 false 时,为发送方生成一个临时 'X25519' 密钥对,将临时公钥导出为封装容器字节,通过 deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256) 派生 256 位,并通过 'HKDF' ('SHA-256') 传递这些位,以派生 256 位 'AES-GCM' 密钥。
const KEM_PUBLIC_KEY_ALGORITHMS = {
  1184: 'ML-KEM-768',
  1216: 'MLKEM768-X25519',
  1568: 'ML-KEM-1024',
};

export function supportsPostQuantumEnvelope() {
  return (
    typeof globalThis.SubtleCrypto?.supports === 'function' &&
    SubtleCrypto.supports('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305')
  );
}

export async function generateRecipientKeys() {
  if (supportsPostQuantumEnvelope()) {
    const keyPair = await crypto.subtle.generateKey(
      'MLKEM768-X25519',
      false,
      ['encapsulateKey', 'decapsulateKey'],
    );
    const publicKeyBytes = new Uint8Array(
      await crypto.subtle.exportKey('raw-public', keyPair.publicKey),
    );
    return { suite: 'MLKEM768-X25519+ChaCha20-Poly1305', keyPair, publicKeyBytes };
  }

  // Classical fallback: X25519 key agreement + HKDF + AES-GCM
  const keyPair = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
  const publicKeyBytes = new Uint8Array(
    await crypto.subtle.exportKey('raw', keyPair.publicKey),
  );
  return { suite: 'X25519+HKDF+AES-GCM', keyPair, publicKeyBytes };
}

async function deriveFallbackAesKey(privateKey, peerPublicKey, infoBytes = new Uint8Array(0)) {
  const sharedSecret = await crypto.subtle.deriveBits(
    { name: 'X25519', public: peerPublicKey },
    privateKey,
    256,
  );
  const hkdfKey = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']);
  return crypto.subtle.deriveKey(
    {
      name: 'HKDF',
      hash: 'SHA-256',
      salt: new Uint8Array(32),
      info: infoBytes,
    },
    hkdfKey,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt'],
  );
}

export async function encapsulateEnvelopeKey(
  recipientPublicKeyOrBytes,
  infoBytes = new Uint8Array(0),
) {
  const pubKey =
    recipientPublicKeyOrBytes instanceof CryptoKey
      ? recipientPublicKeyOrBytes
      : await (() => {
          const bytes =
            recipientPublicKeyOrBytes instanceof Uint8Array
              ? recipientPublicKeyOrBytes
              : new Uint8Array(recipientPublicKeyOrBytes);
          const kemName = KEM_PUBLIC_KEY_ALGORITHMS[bytes.byteLength];
          return kemName
            ? crypto.subtle.importKey('raw-public', bytes, kemName, true, ['encapsulateKey'])
            : crypto.subtle.importKey('raw', bytes, 'X25519', true, []);
        })();

  if (pubKey.algorithm.name !== 'X25519') {
    const { sharedKey, ciphertext } = await crypto.subtle.encapsulateKey(
      pubKey.algorithm.name,
      pubKey,
      'ChaCha20-Poly1305',
      false,
      ['encrypt', 'decrypt'],
    );
    return { sharedKey, capsuleBytes: new Uint8Array(ciphertext) };
  }

  // Classical fallback: generate ephemeral X25519 key pair and send ephemeral public key as capsule
  const ephemeral = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
  const capsuleBytes = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));
  const sharedKey = await deriveFallbackAesKey(ephemeral.privateKey, pubKey, infoBytes);
  return { sharedKey, capsuleBytes };
}

export async function decapsulateEnvelopeKey(
  recipientPrivateKey,
  capsuleBytes,
  infoBytes = new Uint8Array(0),
) {
  if (recipientPrivateKey.algorithm.name !== 'X25519') {
    return crypto.subtle.decapsulateKey(
      recipientPrivateKey.algorithm.name,
      recipientPrivateKey,
      capsuleBytes,
      'ChaCha20-Poly1305',
      false,
      ['encrypt', 'decrypt'],
    );
  }

  const ephemeralPubKey = await crypto.subtle.importKey('raw', capsuleBytes, 'X25519', true, []);
  return deriveFallbackAesKey(recipientPrivateKey, ephemeralPubKey, infoBytes);
}