Używaj interfejsu Web Cryptography API (crypto.subtle), aby ustanawiać wspólne obiekty tajne w niezaufanych sieciach za pomocą odpornych na komputery kwantowe mechanizmów enkapsulacji kluczy (KEM) lub klasycznego uzgadniania kluczy oraz szyfrować dane wrażliwe w przeglądarce za pomocą szyfrowania uwierzytelnionego z danymi powiązanymi (AEAD). Informacje o podpisywaniu i weryfikowaniu wiadomości lub artefaktów bez ich szyfrowania znajdziesz w artykule Podpisywanie i weryfikowanie danych za pomocą WebCrypto.
Proces pełnego szyfrowania kopertowego dzieli się na 2 fazy:
- Jednorazowa konfiguracja odbiorcy (kroki 1–2): odbiorca generuje parę kluczy, zapisuje klucz
CryptoKeyPairw IndexedDB na potrzeby przyszłych sesji i eksportuje klucz publiczny ('raw-public'), aby udostępnić go nadawcom. - Szyfrowanie poszczególnych wiadomości (kroki 3–4): w przypadku każdego ładunku nadawca importuje klucz publiczny odbiorcy, wywołuje funkcję
encapsulateKey(), aby wygenerować nowy jednorazowy klucz wspólny i kapsułę tekstu szyfrowanego, szyfruje ładunek za pomocą nowego 12-bajtowego wektora inicjującego i wysyła{ capsule, iv, ciphertext }do odbiorcy.
1. Wybieranie algorytmu KEM i generowanie pary kluczy odbiorcy
W odróżnieniu od uzgadniania kluczy Diffiego-Hellmana (deriveKey()), gdzie obie strony łączą swoje klucze, mechanizm enkapsulacji kluczy (KEM) używa klucza publicznego odbiorcy do wygenerowania zarówno losowego udostępnionego klucza tajnego, jak i kapsułki tekstu szyfrowanego w jednym wywołaniu encapsulateKey(). Odbiorca odzyskuje ten sam klucz udostępniony z kapsuły tekstu szyfrowanego za pomocą decapsulateKey().
Domyślnie używaj 'MLKEM768-X25519': łączy on postkwantowy algorytm ML-KEM (standaryzowany w NIST FIPS 203) z klasycznym 'X25519', dzięki czemu wspólny obiekt tajny pozostaje bezpieczny, dopóki działa którykolwiek z algorytmów. Wybierz samodzielny algorytm 'ML-KEM-768', gdy wymagania wynikające z zasad wymagają czystych danych wyjściowych FIPS 203 bez komponentu hybrydowego, lub 'ML-KEM-1024', gdy wymagania wynikające z zasad wymagają zgodności z kategorią 5 NIST (CNSA 2.0).
| Nazwa algorytmu | Poziom bezpieczeństwa i przypadek użycia | Klucz publiczny ('raw-public') |
Prywatny ciąg inicjujący ('raw-seed') |
Rozmiar tekstu szyfrowanego |
|---|---|---|---|---|
'MLKEM768-X25519' |
Hybrydowy postkwantowy ('ML-KEM-768') + klasyczny ('X25519'); zalecane ustawienie domyślne |
1216 bajtów | 32 bajty | 1120 bajtów |
'ML-KEM-768' |
NIST kategoria 3 (zabezpieczenia ~192-bitowe); samodzielny postkwantowy mechanizm KEM | 1184 bajty | 64 bajty | 1088 bajtów |
'ML-KEM-1024' |
NIST Category 5 (zabezpieczenia ~256-bitowe); najwyższy margines bezpieczeństwa | 1568 bajtów | 64 bajty | 1568 bajtów |
- Przekaż
'MLKEM768-X25519','ML-KEM-768'lub'ML-KEM-1024'jako nazwę algorytmu (aliasy takie jak'X-Wing'lub'X25519MLKEM768', a także'ML-KEM-512', zwracająNotSupportedError). - Podczas generowania pary kluczy KEM przekaż
extractable: falsei['encapsulateKey', 'decapsulateKey'](lub['encapsulateBits', 'decapsulateBits']). ObiektyCryptoKeyiCryptoKeyPair, których nie można wyodrębnić, mogą być przechowywane między sesjami bezpośrednio w IndexedDB za pomocą klonowania strukturalnego (objectStore.put(recipientKeyPair, 'recipient-kem-key')) bez eksportowania bajtów klucza prywatnego:
const recipientKeyPair = await crypto.subtle.generateKey(
'MLKEM768-X25519',
false,
['encapsulateKey', 'decapsulateKey'],
);
2. Eksportowanie i importowanie kluczy
Nowoczesne algorytmy WebCrypto ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024' i 'ChaCha20-Poly1305') odrzucają starszy format 'raw' w importKey() i exportKey() z NotSupportedError. Zawsze podawaj jawny format klucza:
'raw-public': bajty klucza publicznego (1216,1184lub1568bajtów). Podczas importowania klucza publicznego KEM przekaż['encapsulateKey'](lub['encapsulateBits']).'raw-seed': bajty zasiewu klucza prywatnego (32bajty w przypadku'MLKEM768-X25519';64bajty w przypadku'ML-KEM-768'i'ML-KEM-1024'). Przekaż['decapsulateKey'](lub['decapsulateBits']) podczas importowania klucza prywatnego KEM (przekazanie['encapsulateKey', 'decapsulateKey']doimportKey()lubgetPublicKey()powoduje błądSyntaxError). Zarówno'raw', jak i'raw-private'powodują błądNotSupportedErrorw przypadku kluczy prywatnych KEM. Pamiętaj, że'MLKEM768-X25519'obsługuje tylko'raw-public','raw-seed'i'jwk'– nie obsługuje'spki'ani'pkcs8'.'raw-secret': bajty symetrycznego klucza tajnego ('ChaCha20-Poly1305', akceptowane w'AES-GCM'i'HKDF').
Wyeksportuj klucz publiczny odbiorcy za pomocą 'raw-public', aby bajty klucza publicznego można było zserializować (np. jako Base64 za pomocą new Uint8Array(recipientPublicKeyBytes).toBase64() lub szesnastkowo) i udostępnić nadawcom:
// 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'],
);
Jeśli masz tylko prywatny klucz CryptoKey (nawet z extractable: false) i potrzebujesz odpowiadającego mu klucza publicznego CryptoKey, wywołaj crypto.subtle.getPublicKey():
const publicKey = await crypto.subtle.getPublicKey(
recipientKeyPair.privateKey,
['encapsulateKey'],
);
3. Enkapsulacja i dekapsulacja klucza udostępnionego
encapsulateKey() i decapsulateKey() importują 32-bajtowy (256-bitowy) wspólny obiekt tajny KEM do CryptoKey za pomocą algorytmu określonego w sharedKeyAlgorithm (w formacie 'raw-secret'). Przekaż bezpośrednio 'ChaCha20-Poly1305' lub 'AES-GCM', aby wygenerować klucz szyfrowania 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'],
);
Rozdzielanie domen za pomocą 'HKDF'
Aby powiązać klucz udostępniony z konkretnym kontekstem protokołu (info i salt) lub wygenerować wiele podkluczy z jednej enkapsulacji, przekaż 'HKDF' jako sharedKeyAlgorithm z użyciem ['deriveKey'], a następnie wywołaj 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. szyfrowanie i odszyfrowywanie ładunków ('ChaCha20-Poly1305' i 'AES-GCM');
Zaszyfruj dane ładunku za pomocą algorytmu AEAD ('ChaCha20-Poly1305' lub 'AES-GCM'), używając nowego, kryptograficznie losowego 12-bajtowego (96-bitowego) wektora inicjującego (iv) dla każdej operacji szyfrowania. Nigdy nie używaj ponownie pary (iv, key). Wybierz 'ChaCha20-Poly1305', aby uzyskać stałą wydajność na urządzeniach bez dedykowanej akceleracji sprzętowej AES, lub 'AES-GCM', aby zapewnić współdziałanie z istniejącymi wdrożeniami WebCrypto lub FIPS 140.
'ChaCha20-Poly1305': używa stałego klucza 256-bitowego (32-bajtowego) (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt'])przyjmuje ciąg znaków lub{ name: 'ChaCha20-Poly1305' }bez właściwościlength). Wartośćivmusi mieć dokładnie 12 bajtów, a do tekstu szyfrowanego jest dołączany 128-bitowy tag uwierzytelniania Poly1305.'AES-GCM': przekazuj{ name: 'AES-GCM', length: 256 }podczas wywoływaniagenerateKey()lubderiveKey()oraz 12-bajtowyivpodczas wywoływaniaencrypt()idecrypt().additionalData(AAD): powiąż niezaszyfrowane metadane (takie jak identyfikator rekordu, identyfikator odbiorcy lub wersja protokołu) z tagiem uwierzytelniania za pomocąadditionalData, aby ingerencja w tekst szyfrowany lub metadane powodowała odrzucenie przezdecrypt()z błędemOperationError.
/**
* 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);
}
Strategie zastępcze
Jeśli Twój cel Baseline nie obsługuje jeszcze SubtleCrypto.supports(), encapsulateKey() ani 'ChaCha20-Poly1305', wykryj obsługę za pomocą metody static SubtleCrypto.supports() i użyj tymczasowo-statycznego uzgodnienia klucza protokołu Diffiego-Hellmana ('X25519' + 'HKDF' + 'AES-GCM' lub 'ECDH' z 'P-256' w przypadku przeglądarek, które nie obsługują jeszcze 'X25519'):
- Wywołaj
SubtleCrypto.supports()w konstruktorzeSubtleCrypto, a nie wcrypto.subtle:globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305')to synchroniczna metoda statyczna zwracająca wartość logiczną (crypto.subtle.supportstoundefined). Pamiętaj, żeSubtleCrypto.supports()sprawdza parametry związane z operacją: ciągi algorytmów bez dodatkowych informacji działają w przypadku'encapsulateKey','decapsulateKey','generateKey','importKey','exportKey'i'getPublicKey', natomiast bezpośrednie sondowanie'encrypt'lub'decrypt'wymagaiv(na przykładSubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })). - Format klucza w przypadku zastępczego (
'raw'): przeglądarki obsługujące Modern WebCrypto akceptują też'raw-public'w przypadku'X25519'i'ECDH', ale w ścieżkach zastępczych kierowanych na starsze przeglądarki używaj'raw'. - Klasyczny mechanizm zastępczy (
'X25519'+'HKDF'+'AES-GCM'): gdySubtleCrypto.supports()jest niedostępny lub zwracafalse, wygeneruj efemeryczną parę kluczy'X25519'dla nadawcy, wyeksportuj efemeryczny klucz publiczny jako bajty kapsułki, uzyskaj 256 bitów za pomocąderiveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256)i przekaż te bity przez'HKDF'('SHA-256'), aby uzyskać 256-bitowy klucz'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);
}