使用 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);
}