Szyfrowanie danych po stronie klienta za pomocą WebCrypto

Funkcje internetowe: Kryptografia internetowa

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 CryptoKeyPair w 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: false i ['encapsulateKey', 'decapsulateKey'] (lub ['encapsulateBits', 'decapsulateBits']). Obiekty CryptoKey i CryptoKeyPair, 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, 1184 lub 1568 bajtów). Podczas importowania klucza publicznego KEM przekaż ['encapsulateKey'] (lub ['encapsulateBits']).
  • 'raw-seed': bajty zasiewu klucza prywatnego (32 bajty w przypadku 'MLKEM768-X25519'; 64 bajty w przypadku 'ML-KEM-768' i 'ML-KEM-1024'). Przekaż ['decapsulateKey'] (lub ['decapsulateBits']) podczas importowania klucza prywatnego KEM (przekazanie ['encapsulateKey', 'decapsulateKey'] do importKey() lub getPublicKey() powoduje błąd SyntaxError). Zarówno 'raw', jak i 'raw-private' powodują błąd NotSupportedError w 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ści length). Wartość iv musi 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ływania generateKey() lub deriveKey() oraz 12-bajtowy iv podczas wywoływania encrypt() i decrypt().
  • 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 przez decrypt() z błędem 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);
}

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 konstruktorze SubtleCrypto, a nie w crypto.subtle: globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305') to synchroniczna metoda statyczna zwracająca wartość logiczną (crypto.subtle.supports to undefined). Pamiętaj, że SubtleCrypto.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' wymaga iv (na przykład SubtleCrypto.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'): gdy SubtleCrypto.supports() jest niedostępny lub zwraca false, 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);
}