Gegevens aan de clientzijde versleutelen met WebCrypto

Webfuncties: Webcryptografie

Gebruik de Web Cryptography API (crypto.subtle) om gedeelde geheimen tot stand te brengen via niet-vertrouwde netwerken met behulp van kwantumbestendige Key Encapsulation Mechanisms (KEM's) of klassieke sleutelovereenkomsten, en versleutel gevoelige gegevens in de browser met Authenticated Encryption with Associated Data (AEAD). Ga naar Gegevens ondertekenen en verifiëren met WebCrypto voor meer informatie over hoe je berichten of artefacten ondertekent en verifieert zonder ze te versleutelen.

Een end-to-end proces voor envelopversleuteling bestaat uit 2 fasen:

  • Eenmalige instelling door de ontvanger (stappen 1-2): De ontvanger genereert een sleutelpaar, bewaart de CryptoKeyPair in IndexedDB voor toekomstige sessies en exporteert de openbare sleutel ('raw-public') om deze te delen met afzenders.
  • Versleuteling per bericht (stap 3-4): Voor elke payload importeert de afzender de openbare sleutel van de ontvanger, roept encapsulateKey() aan om een nieuwe eenmalige gedeelde sleutel en ciphertext-capsule te genereren, versleutelt de payload met een nieuwe IV van 12 bytes en stuurt { capsule, iv, ciphertext } naar de ontvanger.

1. Kies een KEM-algoritme en genereer een sleutelpaar voor de ontvanger

In tegenstelling tot de Diffie-Hellman-sleutelovereenkomst (deriveKey()), waarbij beide partijen hun sleutels combineren, gebruikt een Key Encapsulation Mechanism (KEM) de openbare sleutel van de ontvanger om zowel een willekeurig gedeeld geheim als een ciphertext-capsule te genereren in één encapsulateKey()-aanroep. De ontvanger herstelt dezelfde gedeelde sleutel uit de capsule met de versleutelde tekst met decapsulateKey().

Gebruik standaard 'MLKEM768-X25519': Dit combineert post-quantum ML-KEM (gestandaardiseerd in NIST FIPS 203) met klassieke 'X25519', zodat het gedeelde geheim beveiligd blijft zolang een van beide algoritmen standhoudt. Kies de zelfstandige 'ML-KEM-768' als een beleid pure FIPS 203-uitvoer vereist zonder hybride component, of 'ML-KEM-1024' als een beleid naleving van NIST-categorie 5 (CNSA 2.0) vereist.

Algoritmenaam Beveiligingsniveau en use case Openbare sleutel ('raw-public') Privé-seed ('raw-seed') Grootte van versleutelde tekst
'MLKEM768-X25519' Hybride post-quantum ('ML-KEM-768') + klassiek ('X25519'); aanbevolen standaard 1216 bytes 32 bytes 1120 bytes
'ML-KEM-768' NIST-categorie 3 (~192-bits beveiliging); zelfstandige post-kwantum KEM 1184 bytes 64 bytes 1088 bytes
'ML-KEM-1024' NIST-categorie 5 (~256-bits beveiliging); hoogste beveiligingsmarge 1568 bytes 64 bytes 1568 bytes
  • Geef 'MLKEM768-X25519', 'ML-KEM-768' of 'ML-KEM-1024' door als de algoritmenaam (aliassen zoals 'X-Wing' of 'X25519MLKEM768', en 'ML-KEM-512', genereren NotSupportedError).
  • Geef extractable: false en ['encapsulateKey', 'decapsulateKey'] (of ['encapsulateBits', 'decapsulateBits']) door als je een KEM-sleutelpaar genereert. Niet-extraheerbare CryptoKey- en CryptoKeyPair-objecten kunnen rechtstreeks in IndexedDB worden bewaard via gestructureerde klonen (objectStore.put(recipientKeyPair, 'recipient-kem-key')) zonder de bytes van de privésleutel te exporteren:
const recipientKeyPair = await crypto.subtle.generateKey(
  'MLKEM768-X25519',
  false,
  ['encapsulateKey', 'decapsulateKey'],
);

2. Sleutels exporteren en importeren

Moderne WebCrypto-algoritmen ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024' en 'ChaCha20-Poly1305') weigeren de verouderde 'raw'-indeling in importKey() en exportKey() met een NotSupportedError. Geef altijd de expliciete sleutelindeling op:

  • 'raw-public': Bytes van openbare sleutel (1216, 1184 of 1568 bytes). Geef ['encapsulateKey'] (of ['encapsulateBits']) door als je een openbare KEM-sleutel importeert.
  • 'raw-seed': Privésleutel-seedbytes (32 bytes voor 'MLKEM768-X25519'; 64 bytes voor 'ML-KEM-768' en 'ML-KEM-1024'). Geef ['decapsulateKey'] (of ['decapsulateBits']) door bij het importeren van een KEM-privésleutel (als je ['encapsulateKey', 'decapsulateKey'] doorgeeft aan importKey() of getPublicKey(), wordt SyntaxError geactiveerd). Zowel 'raw' als 'raw-private' activeren NotSupportedError op KEM-privésleutels. 'MLKEM768-X25519' ondersteunt alleen 'raw-public', 'raw-seed' en 'jwk'. 'spki' en 'pkcs8' worden niet ondersteund.
  • 'raw-secret': Symmetrische geheime sleutelbytes ('ChaCha20-Poly1305', en geaccepteerd op 'AES-GCM' en 'HKDF').

Exporteer de openbare sleutel van de ontvanger met 'raw-public', zodat de bytes van de openbare sleutel kunnen worden geserialiseerd (bijvoorbeeld als Base64 via new Uint8Array(recipientPublicKeyBytes).toBase64() of hex) en gedeeld met afzenders:

// 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'],
);

Als je alleen een privé-CryptoKey hebt (zelfs met extractable: false) en de bijbehorende openbare CryptoKey nodig hebt, roep je crypto.subtle.getPublicKey() aan:

const publicKey = await crypto.subtle.getPublicKey(
  recipientKeyPair.privateKey,
  ['encapsulateKey'],
);

3. Een gedeelde sleutel inkapselen en ontkapselen

encapsulateKey() en decapsulateKey() importeren het gedeelde KEM-geheim van 32 bytes (256 bits) in een CryptoKey met het algoritme dat is gespecificeerd in sharedKeyAlgorithm (met de indeling 'raw-secret'). Geef 'ChaCha20-Poly1305' of 'AES-GCM' rechtstreeks door om een AEAD-versleutelingssleutel te maken:

// 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'],
);

Domeinscheiding met 'HKDF'

Als je de gedeelde sleutel wilt binden aan een specifieke protocolcontext (info en salt) of meerdere subsleutels wilt afleiden van één inkapseling, geef je 'HKDF' door als sharedKeyAlgorithm met ['deriveKey']-gebruik en roep je crypto.subtle.deriveKey() aan:

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. Payloads versleutelen en ontsleutelen ('ChaCha20-Poly1305' en 'AES-GCM')

Versleutel payloadgegevens met een AEAD-algoritme ('ChaCha20-Poly1305' of 'AES-GCM') met een nieuwe, cryptografisch willekeurige initialisatievector van 12 bytes (96 bits) (iv) voor elke versleutelingsbewerking. Gebruik een (iv, key)-paar nooit opnieuw. Kies 'ChaCha20-Poly1305' voor consistente prestaties met constante tijd op verschillende apparaten zonder speciale AES-hardwareversnelling, of 'AES-GCM' bij interoperabiliteit met bestaande WebCrypto- of FIPS 140-implementaties.

  • 'ChaCha20-Poly1305': Gebruikt een vaste 256-bits (32-byte) sleutel (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt']) neemt een tekenreeks of { name: 'ChaCha20-Poly1305' } met geen length-property). De iv moet precies 12 bytes zijn en de 128-bits Poly1305-verificatietag wordt toegevoegd aan de cijfertekst.
  • 'AES-GCM': Geef { name: 'AES-GCM', length: 256 } door bij het aanroepen van generateKey() of deriveKey() en een 12-byte iv bij het aanroepen van encrypt() en decrypt().
  • additionalData (AAD): Bind niet-versleutelde metadata (zoals een record-ID, ontvanger-ID of protocolversie) aan de verificatietag via additionalData, zodat manipulatie van de cijfertekst of de metadata ertoe leidt dat decrypt() wordt geweigerd met 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);
}

Reservestrategieën

Als je Baseline-doel nog geen SubtleCrypto.supports(), encapsulateKey() of 'ChaCha20-Poly1305' ondersteunt, detecteer je ondersteuning met de static SubtleCrypto.supports()-methode en val je terug op kortstondige statische Diffie-Hellman-sleutelovereenkomst ('X25519' + 'HKDF' + 'AES-GCM', of 'ECDH' met 'P-256' als je browsers target van vóór 'X25519'-ondersteuning):

  • Roep SubtleCrypto.supports() aan op de SubtleCrypto-constructor, niet op crypto.subtle: globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305') is een synchrone statische methode die een Booleaanse waarde retourneert (crypto.subtle.supports is undefined). SubtleCrypto.supports() valideert bewerkingsspecifieke parameters: kale algoritmetekenreeksen werken voor 'encapsulateKey', 'decapsulateKey', 'generateKey', 'importKey', 'exportKey' en 'getPublicKey', terwijl het rechtstreeks testen van 'encrypt' of 'decrypt' een iv vereist (bijvoorbeeld SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })).
  • Sleutelindeling in de fallback ('raw'): Hoewel browsers die Modern WebCrypto ondersteunen ook 'raw-public' accepteren op 'X25519' en 'ECDH', gebruik je 'raw' in fallback-paden die oudere browsers targeten.
  • Klassieke fallback ('X25519' + 'HKDF' + 'AES-GCM'): Als SubtleCrypto.supports() niet beschikbaar is of false retourneert, genereer je een kortstondig 'X25519'-sleutelpaar voor de afzender, exporteer je de kortstondige openbare sleutel als de capsulebytes, leid je 256 bits af via deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256) en geef je die bits door via 'HKDF' ('SHA-256') om een 256-bits 'AES-GCM'-sleutel af te leiden.
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);
}