Daten clientseitig mit WebCrypto verschlüsseln

Webfunktionen: Web Cryptography

Mit der Web Cryptography API (crypto.subtle) können Sie über nicht vertrauenswürdige Netzwerke mithilfe von quantenresistenten Key Encapsulation Mechanisms (KEMs) oder klassischer Schlüsselvereinbarung gemeinsame Secrets einrichten und sensible Daten im Browser mit Authenticated Encryption with Associated Data (AEAD) verschlüsseln. Informationen zum Signieren und Prüfen von Nachrichten oder Artefakten ohne Verschlüsselung finden Sie unter Daten mit WebCrypto signieren und prüfen.

Ein End-to-End-Umschlagverschlüsselungsablauf ist in zwei Phasen unterteilt:

  • Einmalige Einrichtung für Empfänger (Schritte 1–2): Der Empfänger generiert ein Schlüsselpaar, speichert den CryptoKeyPair in IndexedDB für zukünftige Sitzungen und exportiert den öffentlichen Schlüssel ('raw-public'), um ihn mit Absendern zu teilen.
  • Verschlüsselung pro Nachricht (Schritte 3–4): Für jede Nutzlast importiert der Absender den öffentlichen Schlüssel des Empfängers, ruft encapsulateKey() auf, um einen neuen einmaligen gemeinsamen Schlüssel und eine Chiffretext-Kapsel zu generieren, verschlüsselt die Nutzlast mit einem neuen 12-Byte-IV und sendet { capsule, iv, ciphertext } an den Empfänger.

1. KEM-Algorithmus auswählen und Empfängerschlüsselpaar generieren

Im Gegensatz zur Diffie-Hellman-Schlüsselvereinbarung (deriveKey()), bei der beide Parteien ihre Schlüssel kombinieren, wird bei einem Key Encapsulation Mechanism (KEM) der öffentliche Schlüssel des Empfängers verwendet, um sowohl ein zufälliges gemeinsames Secret als auch eine Chiffretext-Kapsel in einem einzigen encapsulateKey()-Aufruf zu generieren. Der Empfänger stellt denselben gemeinsamen Schlüssel mit decapsulateKey() aus der Chiffretext-Kapsel wieder her.

Verwenden Sie standardmäßig 'MLKEM768-X25519': Es kombiniert Post-Quanten-ML-KEM (standardisiert in NIST FIPS 203) mit klassischem 'X25519', sodass das gemeinsame Secret sicher bleibt, solange einer der beiden Algorithmen gilt. Wählen Sie die eigenständige 'ML-KEM-768'-Variante, wenn eine Richtlinie eine reine FIPS 203-Ausgabe ohne Hybridkomponente erfordert, oder 'ML-KEM-1024', wenn eine Richtlinie die Einhaltung von NIST-Kategorie 5 (CNSA 2.0) erfordert.

Algorithmusname Sicherheitsniveau und Anwendungsfall Öffentlicher Schlüssel ('raw-public') Privater Seed ('raw-seed') Geheimtextgröße
'MLKEM768-X25519' Hybrid-Post-Quantum ('ML-KEM-768') + klassisch ('X25519'); empfohlene Standardeinstellung 1.216 Byte 32 Byte 1.120 Byte
'ML-KEM-768' NIST-Kategorie 3 (~192-Bit-Sicherheit); eigenständige Post-Quanten-KEM 1.184 Byte 64 Byte 1.088 Byte
'ML-KEM-1024' NIST-Kategorie 5 (~256-Bit-Sicherheit); höchste Sicherheitsmarge 1.568 Byte 64 Byte 1.568 Byte
  • Übergeben Sie 'MLKEM768-X25519', 'ML-KEM-768' oder 'ML-KEM-1024' als Algorithmusnamen (Aliasse wie 'X-Wing' oder 'X25519MLKEM768' sowie 'ML-KEM-512' lösen NotSupportedError aus).
  • Übergeben Sie extractable: false und ['encapsulateKey', 'decapsulateKey'] (oder ['encapsulateBits', 'decapsulateBits']), wenn Sie ein KEM-Schlüsselpaar generieren. Nicht extrahierbare CryptoKey- und CryptoKeyPair-Objekte können über strukturiertes Klonen (objectStore.put(recipientKeyPair, 'recipient-kem-key')) direkt in IndexedDB sitzungsübergreifend gespeichert werden, ohne dass die privaten Schlüsselbytes exportiert werden müssen:
const recipientKeyPair = await crypto.subtle.generateKey(
  'MLKEM768-X25519',
  false,
  ['encapsulateKey', 'decapsulateKey'],
);

2. Schlüssel exportieren und importieren

Moderne WebCrypto-Algorithmen ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024' und 'ChaCha20-Poly1305') lehnen das alte 'raw'-Format in importKey() und exportKey() mit einem NotSupportedError ab. Geben Sie immer das explizite Schlüsselformat an:

  • 'raw-public': Byte des öffentlichen Schlüssels (1216-, 1184- oder 1568-Byte). Übergeben Sie ['encapsulateKey'] (oder ['encapsulateBits']), wenn Sie einen öffentlichen KEM-Schlüssel importieren.
  • 'raw-seed': Seed-Bytes des privaten Schlüssels (32 Byte für 'MLKEM768-X25519'; 64 Byte für 'ML-KEM-768' und 'ML-KEM-1024'). Übergeben Sie ['decapsulateKey'] (oder ['decapsulateBits']), wenn Sie einen privaten KEM-Schlüssel importieren. Wenn Sie ['encapsulateKey', 'decapsulateKey'] an importKey() oder getPublicKey() übergeben, wird SyntaxError ausgelöst. Sowohl 'raw' als auch 'raw-private' lösen NotSupportedError für private KEM-Schlüssel aus. Beachten Sie, dass 'MLKEM768-X25519' nur 'raw-public', 'raw-seed' und 'jwk' unterstützt. 'spki' und 'pkcs8' werden nicht unterstützt.
  • 'raw-secret': Symmetrische Secret-Schlüssel-Bytes ('ChaCha20-Poly1305', akzeptiert auf 'AES-GCM' und 'HKDF').

Exportieren Sie den öffentlichen Schlüssel des Empfängers mit 'raw-public', damit die Byte des öffentlichen Schlüssels serialisiert (z. B. als Base64 über new Uint8Array(recipientPublicKeyBytes).toBase64() oder Hex) und mit Absendern geteilt werden können:

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

Wenn Sie nur einen privaten CryptoKey (auch mit extractable: false) haben und den entsprechenden öffentlichen CryptoKey benötigen, rufen Sie crypto.subtle.getPublicKey() auf:

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

3. Geteilten Schlüssel kapseln und entkapseln

encapsulateKey() und decapsulateKey() importieren das 32-Byte-KEM-Shared-Secret (256 Bit) in einen CryptoKey. Dabei wird der in sharedKeyAlgorithm angegebene Algorithmus (mit dem Format 'raw-secret') verwendet. Übergeben Sie 'ChaCha20-Poly1305' oder 'AES-GCM' direkt, um einen AEAD-Verschlüsselungsschlüssel zu erstellen:

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

Domänen-Trennung mit 'HKDF'

Wenn Sie den freigegebenen Schlüssel an einen bestimmten Protokollkontext (info und salt) binden oder mehrere Unterschlüssel aus einer einzelnen Kapselung ableiten möchten, übergeben Sie 'HKDF' als sharedKeyAlgorithm mit der Verwendung ['deriveKey'] und rufen Sie dann crypto.subtle.deriveKey() auf:

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. Nutzlasten verschlüsseln und entschlüsseln ('ChaCha20-Poly1305' und 'AES-GCM')

Verschlüsseln Sie Nutzlastdaten mit einem AEAD-Algorithmus ('ChaCha20-Poly1305' oder 'AES-GCM') und verwenden Sie für jeden Verschlüsselungsvorgang einen neuen, kryptografisch zufälligen 12-Byte-Initialisierungsvektor (96 Bit) (iv). Verwenden Sie ein (iv, key)-Paar niemals wieder. Wählen Sie 'ChaCha20-Poly1305' für eine konsistente Leistung mit konstanter Zeit auf Geräten ohne dedizierte AES-Hardwarebeschleunigung oder 'AES-GCM' für die Interoperabilität mit vorhandenen WebCrypto- oder FIPS 140-Bereitstellungen.

  • 'ChaCha20-Poly1305': Verwendet einen festen 256-Bit-Schlüssel (32 Byte). generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt']) akzeptiert einen String oder { name: 'ChaCha20-Poly1305' } ohne length-Attribut. Die iv muss genau 12 Byte lang sein und das 128-Bit-Poly1305-Authentifizierungs-Tag wird an den Chiffretext angehängt.
  • 'AES-GCM': Übergeben Sie { name: 'AES-GCM', length: 256 }, wenn Sie generateKey() oder deriveKey() aufrufen, und ein 12-Byte-iv, wenn Sie encrypt() und decrypt() aufrufen.
  • additionalData (AAD): Unverschlüsselte Metadaten (z. B. eine Datensatz-ID, Empfänger-ID oder Protokollversion) werden über additionalData an das Authentifizierungs-Tag gebunden, sodass Manipulationen am Geheimtext oder an den Metadaten dazu führen, dass decrypt() mit OperationError abgelehnt wird.
/**
 * 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);
}

Fallback-Strategien

Wenn Ihr Baseline-Ziel SubtleCrypto.supports(), encapsulateKey() oder 'ChaCha20-Poly1305' noch nicht unterstützt, können Sie die Unterstützung mit der statischen SubtleCrypto.supports()-Methode erkennen und auf die ephemer-statische Diffie-Hellman-Schlüsselvereinbarung ('X25519' + 'HKDF' + 'AES-GCM' oder 'ECDH' mit 'P-256', wenn Sie Browser vor der 'X25519'-Unterstützung verwenden) zurückgreifen:

  • Rufen Sie SubtleCrypto.supports() im SubtleCrypto-Konstruktor auf, nicht in crypto.subtle: globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305') ist eine synchrone statische Methode, die einen booleschen Wert zurückgibt (crypto.subtle.supports ist undefined). Beachten Sie, dass SubtleCrypto.supports() vorgangsspezifische Parameter validiert: Reine Algorithmus-Strings funktionieren für 'encapsulateKey', 'decapsulateKey', 'generateKey', 'importKey', 'exportKey' und 'getPublicKey', während für die direkte Prüfung von 'encrypt' oder 'decrypt' ein iv erforderlich ist (z. B. SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })).
  • Schlüsselformat im Fallback ('raw'): Browser, die Modern WebCrypto unterstützen, akzeptieren zwar auch 'raw-public' für 'X25519' und 'ECDH', aber in Fallback-Pfaden für ältere Browser sollten Sie 'raw' verwenden.
  • Klassischer Fallback ('X25519' + 'HKDF' + 'AES-GCM'): Wenn SubtleCrypto.supports() nicht verfügbar ist oder false zurückgibt, wird ein sitzungsspezifisches 'X25519'-Schlüsselpaar für den Absender generiert, der sitzungsspezifische öffentliche Schlüssel als Kapsel-Bytes exportiert, 256 Bit über deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256) abgeleitet und diese Bits über 'HKDF' ('SHA-256') übergeben, um einen 256-Bit-'AES-GCM'-Schlüssel abzuleiten.
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);
}