使用 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() 方法偵測支援,並改用暫時靜態 Diffie-Hellman 金鑰協議 ('X25519' + 'HKDF' + 'AES-GCM',或在指定不支援 'X25519' 的瀏覽器時使用 'ECDH' 和 'P-256'):
- 在
SubtleCrypto建構函式上呼叫SubtleCrypto.supports(),而不是在crypto.subtle上呼叫: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);
}