Sử dụng Web Cryptography API (crypto.subtle) để thiết lập khoá bí mật dùng chung qua các mạng không đáng tin cậy bằng Cơ chế đóng gói khoá (KEM) kháng lượng tử hoặc thoả thuận khoá cổ điển, đồng thời mã hoá dữ liệu nhạy cảm trong trình duyệt bằng phương thức Mã hoá đã xác thực có dữ liệu liên kết (AEAD). Để ký và xác minh thông báo hoặc cấu phần phần mềm mà không mã hoá, hãy xem bài viết Ký và xác minh dữ liệu bằng WebCrypto.
Quy trình mã hoá phong bì hai đầu được chia thành hai giai đoạn:
- Thiết lập người nhận một lần (Bước 1–2): Người nhận tạo một cặp khoá, lưu giữ
CryptoKeyPairtrong IndexedDB cho các phiên trong tương lai và xuất khoá công khai ('raw-public') để chia sẻ với người gửi. - Mã hoá theo từng thông báo (Bước 3 – 4): Đối với mỗi tải trọng, người gửi sẽ nhập khoá công khai của người nhận, gọi
encapsulateKey()để tạo khoá dùng chung một lần mới và viên nang văn bản mật mã, mã hoá tải trọng bằng IV 12 byte mới và gửi{ capsule, iv, ciphertext }cho người nhận.
1. Chọn thuật toán KEM và tạo cặp khoá người nhận
Không giống như thoả thuận khoá Diffie-Hellman (deriveKey()), trong đó cả hai bên đều kết hợp khoá của mình, Cơ chế đóng gói khoá (KEM) sử dụng khoá công khai của người nhận để tạo cả khoá bí mật dùng chung ngẫu nhiên và gói văn bản mật mã trong một lệnh gọi encapsulateKey() duy nhất. Người nhận khôi phục cùng một khoá dùng chung từ hộp văn bản mật mã bằng decapsulateKey().
Sử dụng 'MLKEM768-X25519' theo mặc định: thuật toán này kết hợp ML-KEM hậu lượng tử (được chuẩn hoá trong NIST FIPS 203) với 'X25519' cổ điển để khoá bí mật dùng chung luôn được bảo mật miễn là một trong hai thuật toán vẫn hoạt động. Chọn 'ML-KEM-768' độc lập khi yêu cầu theo chính sách yêu cầu đầu ra FIPS 203 thuần tuý mà không có thành phần kết hợp hoặc 'ML-KEM-1024' khi yêu cầu theo chính sách yêu cầu tuân thủ NIST Danh mục 5 (CNSA 2.0).
| Tên thuật toán | Mức an ninh và trường hợp sử dụng | Khoá công khai ('raw-public') |
Hạt giống riêng tư ('raw-seed') |
Kích thước văn bản mật mã |
|---|---|---|---|---|
'MLKEM768-X25519' |
Kết hợp hậu lượng tử ('ML-KEM-768') + cổ điển ('X25519'); mặc định được đề xuất |
1216 byte | 32 byte | 1120 byte |
'ML-KEM-768' |
NIST Loại 3 (bảo mật ~192 bit); KEM hậu lượng tử độc lập | 1184 byte | 64 byte | 1088 byte |
'ML-KEM-1024' |
NIST Loại 5 (bảo mật ~256 bit); biên độ bảo mật cao nhất | 1568 byte | 64 byte | 1568 byte |
- Truyền
'MLKEM768-X25519','ML-KEM-768'hoặc'ML-KEM-1024'làm tên thuật toán (các bí danh như'X-Wing'hoặc'X25519MLKEM768', cũng như'ML-KEM-512', sẽ gửiNotSupportedError). - Truyền
extractable: falsevà['encapsulateKey', 'decapsulateKey'](hoặc['encapsulateBits', 'decapsulateBits']) khi tạo cặp khoá KEM. Bạn có thể lưu trữ trực tiếp các đối tượngCryptoKeyvàCryptoKeyPairkhông thể trích xuất trong các phiên trong IndexedDB thông qua tính năng sao chép có cấu trúc (objectStore.put(recipientKeyPair, 'recipient-kem-key')) mà không cần xuất byte khoá riêng tư:
const recipientKeyPair = await crypto.subtle.generateKey(
'MLKEM768-X25519',
false,
['encapsulateKey', 'decapsulateKey'],
);
2. Xuất và nhập khoá
Các thuật toán WebCrypto hiện đại ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024' và 'ChaCha20-Poly1305') từ chối định dạng 'raw' cũ trong importKey() và exportKey() bằng NotSupportedError. Luôn chỉ định định dạng khoá rõ ràng:
'raw-public': Byte khoá công khai (1216,1184hoặc1568byte). Truyền['encapsulateKey'](hoặc['encapsulateBits']) khi nhập khoá công khai KEM.'raw-seed': Byte hạt giống khoá riêng tư (32byte cho'MLKEM768-X25519';64byte cho'ML-KEM-768'và'ML-KEM-1024'). Truyền['decapsulateKey'](hoặc['decapsulateBits']) khi nhập khoá riêng tư KEM (truyền['encapsulateKey', 'decapsulateKey']đếnimportKey()hoặcgetPublicKey()sẽ gửiSyntaxError). Cả'raw'và'raw-private'đều gửiNotSupportedErrortrên khoá riêng tư KEM. Xin lưu ý rằng'MLKEM768-X25519'chỉ hỗ trợ'raw-public','raw-seed'và'jwk', không hỗ trợ'spki'hoặc'pkcs8'.'raw-secret': Byte khoá bí mật đối xứng ('ChaCha20-Poly1305'và được chấp nhận trên'AES-GCM'và'HKDF').
Xuất khoá công khai của người nhận bằng 'raw-public' để có thể chuyển đổi tuần tự các byte khoá công khai (ví dụ: dưới dạng Base64 thông qua new Uint8Array(recipientPublicKeyBytes).toBase64() hoặc hệ thập lục phân) và chia sẻ với người gửi:
// 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'],
);
Nếu bạn chỉ giữ một CryptoKey riêng tư (ngay cả với extractable: false) và cần CryptoKey công khai tương ứng, hãy gọi crypto.subtle.getPublicKey():
const publicKey = await crypto.subtle.getPublicKey(
recipientKeyPair.privateKey,
['encapsulateKey'],
);
3. Đóng gói và giải nén khoá dùng chung
encapsulateKey() và decapsulateKey() nhập khoá bí mật dùng chung KEM 32 byte (256 bit) vào CryptoKey bằng thuật toán được chỉ định trong sharedKeyAlgorithm (với định dạng 'raw-secret'). Truyền trực tiếp 'ChaCha20-Poly1305' hoặc 'AES-GCM' để tạo khoá mã hoá 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'],
);
Phân tách miền bằng 'HKDF'
Để liên kết khoá dùng chung với một bối cảnh giao thức cụ thể (info và salt) hoặc lấy nhiều khoá phụ từ một gói duy nhất, hãy truyền 'HKDF' dưới dạng sharedKeyAlgorithm có mức sử dụng ['deriveKey'], sau đó gọi 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. Mã hoá và giải mã tải trọng ('ChaCha20-Poly1305' và 'AES-GCM')
Mã hoá dữ liệu tải trọng bằng thuật toán AEAD ('ChaCha20-Poly1305' hoặc 'AES-GCM') bằng cách sử dụng vectơ khởi tạo 12 byte (96 bit) (iv) ngẫu nhiên, mới và được mã hoá cho mọi thao tác mã hoá. Không bao giờ sử dụng lại một cặp (iv, key). Chọn 'ChaCha20-Poly1305' để có hiệu suất thời gian không đổi nhất quán trên các thiết bị không có tính năng tăng tốc phần cứng AES chuyên dụng hoặc 'AES-GCM' khi tương tác với các bản triển khai WebCrypto hoặc FIPS 140 hiện có.
'ChaCha20-Poly1305': Sử dụng khoá 256 bit (32 byte) cố định (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt'])lấy một chuỗi hoặc{ name: 'ChaCha20-Poly1305' }không có thuộc tínhlength).ivphải có độ dài chính xác là 12 byte và thẻ xác thực Poly1305 128 bit được thêm vào văn bản mật mã.'AES-GCM': Truyền{ name: 'AES-GCM', length: 256 }khi gọigenerateKey()hoặcderiveKey()vàiv12 byte khi gọiencrypt()vàdecrypt().additionalData(AAD): Liên kết siêu dữ liệu chưa mã hoá (chẳng hạn như mã bản ghi, mã người nhận hoặc phiên bản giao thức) với thẻ xác thực thông quaadditionalDatađể việc can thiệp vào bản mã hoặc siêu dữ liệu khiếndecrypt()từ chối bằngOperationError.
/**
* 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);
}
Chiến lược dự phòng
Nếu mục tiêu Baseline của bạn chưa hỗ trợ SubtleCrypto.supports(), encapsulateKey() hoặc 'ChaCha20-Poly1305', hãy phát hiện khả năng hỗ trợ bằng phương thức static SubtleCrypto.supports() và quay lại thoả thuận khoá Diffie-Hellman tạm thời-tĩnh ('X25519' + 'HKDF' + 'AES-GCM' hoặc 'ECDH' với 'P-256' khi nhắm đến các trình duyệt trước khi hỗ trợ 'X25519'):
- Gọi
SubtleCrypto.supports()trên hàm khởi tạoSubtleCrypto, chứ không phải trêncrypto.subtle:globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305')là một phương thức tĩnh đồng bộ trả về giá trị boolean (crypto.subtle.supportslàundefined). Xin lưu ý rằngSubtleCrypto.supports()xác thực các tham số dành riêng cho thao tác: chuỗi thuật toán thuần tuý hoạt động cho'encapsulateKey','decapsulateKey','generateKey','importKey','exportKey'và'getPublicKey', trong khi việc thăm dò'encrypt'hoặc'decrypt'trực tiếp yêu cầuiv(ví dụ:SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })). - Định dạng khoá trong phương án dự phòng (
'raw'): Mặc dù các trình duyệt hỗ trợ WebCrypto hiện đại cũng chấp nhận'raw-public'trên'X25519'và'ECDH', nhưng hãy sử dụng'raw'trong các đường dẫn dự phòng nhắm đến các trình duyệt cũ. - Dự phòng cổ điển (
'X25519'+'HKDF'+'AES-GCM'): KhiSubtleCrypto.supports()không hoạt động hoặc trả vềfalse, hãy tạo một cặp khoá'X25519'tạm thời cho người gửi, xuất khoá công khai tạm thời dưới dạng byte đóng gói, lấy 256 bit thông quaderiveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256)và truyền các bit đó qua'HKDF'('SHA-256') để lấy khoá'AES-GCM'256 bit.
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);
}