Chiffrer des données côté client avec WebCrypto

Fonctionnalités Web : Web Cryptography

Utilisez l'API Web Cryptography (crypto.subtle) pour établir des secrets partagés sur des réseaux non approuvés à l'aide de mécanismes d'encapsulation de clés (KEM) résistants aux ordinateurs quantiques ou d'un accord de clé classique, et chiffrez les données sensibles dans le navigateur avec le chiffrement authentifié avec données associées (AEAD). Pour signer et valider des messages ou des artefacts sans les chiffrer, consultez Signer et valider des données avec WebCrypto.

Un flux de chiffrement encapsulé de bout en bout se divise en deux phases :

  • Configuration unique du destinataire (étapes 1 et 2) : le destinataire génère une paire de clés, conserve la CryptoKeyPair dans IndexedDB pour les sessions futures et exporte la clé publique ('raw-public') pour la partager avec les expéditeurs.
  • Chiffrement par message (étapes 3 et 4) : pour chaque charge utile, l'expéditeur importe la clé publique du destinataire, appelle encapsulateKey() pour générer une nouvelle clé partagée à usage unique et une capsule de texte chiffré, chiffre la charge utile avec un nouveau vecteur d'initialisation de 12 octets, puis envoie { capsule, iv, ciphertext } au destinataire.

1. Choisir un algorithme KEM et générer une paire de clés de destinataire

Contrairement à l'accord de clé Diffie-Hellman (deriveKey()), où les deux parties combinent leurs clés, un mécanisme d'encapsulation de clé (KEM) utilise la clé publique du destinataire pour générer à la fois un secret partagé aléatoire et une capsule de texte chiffré en un seul appel encapsulateKey(). Le destinataire récupère la même clé partagée à partir de la capsule de texte chiffré à l'aide de decapsulateKey().

Utilisez 'MLKEM768-X25519' par défaut : il combine le ML-KEM post-quantique (normalisé dans NIST FIPS 203) avec le 'X25519' classique, de sorte que le secret partagé reste sécurisé tant que l'un des algorithmes est valide. Choisissez 'ML-KEM-768' autonome lorsqu'une règle nécessite une sortie FIPS 203 pure sans composant hybride, ou 'ML-KEM-1024' lorsqu'une règle nécessite la conformité à la catégorie 5 du NIST (CNSA 2.0).

Nom de l'algorithme Niveau de sécurité et cas d'utilisation Clé publique ('raw-public') Valeur de départ privée ('raw-seed') Taille du texte chiffré
'MLKEM768-X25519' Post-quantique hybride ('ML-KEM-768') + classique ('X25519') ; valeur par défaut recommandée 1216 octets 32 octets 1 120 octets
'ML-KEM-768' NIST de catégorie 3 (sécurité d'environ 192 bits) ; KEM post-quantique autonome 1 184 octets 64 octets 1 088 octets
'ML-KEM-1024' NIST de catégorie 5 (sécurité d'environ 256 bits) ; marge de sécurité la plus élevée 1 568 octets 64 octets 1 568 octets
  • Transmettez 'MLKEM768-X25519', 'ML-KEM-768' ou 'ML-KEM-1024' comme nom d'algorithme (les alias tels que 'X-Wing' ou 'X25519MLKEM768', ainsi que 'ML-KEM-512', génèrent NotSupportedError).
  • Transmettez extractable: false et ['encapsulateKey', 'decapsulateKey'] (ou ['encapsulateBits', 'decapsulateBits']) lors de la génération d'une paire de clés KEM. Les objets CryptoKey et CryptoKeyPair non extractibles peuvent être conservés d'une session à l'autre directement dans IndexedDB via le clonage structuré (objectStore.put(recipientKeyPair, 'recipient-kem-key')) sans jamais exporter les octets de la clé privée :
const recipientKeyPair = await crypto.subtle.generateKey(
  'MLKEM768-X25519',
  false,
  ['encapsulateKey', 'decapsulateKey'],
);

2. Exporter et importer des clés

Les algorithmes WebCrypto modernes ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024' et 'ChaCha20-Poly1305') rejettent l'ancien format 'raw' dans importKey() et exportKey() avec une NotSupportedError. Spécifiez toujours le format de clé explicite :

  • 'raw-public' : octets de la clé publique (octets 1216, 1184 ou 1568). Transmettez ['encapsulateKey'] (ou ['encapsulateBits']) lors de l'importation d'une clé publique KEM.
  • 'raw-seed' : octets de départ de la clé privée (32 octets pour 'MLKEM768-X25519' ; 64 octets pour 'ML-KEM-768' et 'ML-KEM-1024'). Transmettez ['decapsulateKey'] (ou ['decapsulateBits']) lors de l'importation d'une clé privée KEM (la transmission de ['encapsulateKey', 'decapsulateKey'] à importKey() ou getPublicKey() génère SyntaxError). 'raw' et 'raw-private' génèrent NotSupportedError sur les clés privées KEM. Notez que 'MLKEM768-X25519' n'est compatible qu'avec 'raw-public', 'raw-seed' et 'jwk'. Il n'est pas compatible avec 'spki' ou 'pkcs8'.
  • 'raw-secret' : octets de clé secrète symétrique ('ChaCha20-Poly1305', et acceptés sur 'AES-GCM' et 'HKDF').

Exportez la clé publique du destinataire avec 'raw-public' afin que les octets de la clé publique puissent être sérialisés (par exemple, en tant que Base64 via new Uint8Array(recipientPublicKeyBytes).toBase64() ou hexadécimal) et partagés avec les expéditeurs :

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

Si vous ne disposez que d'un CryptoKey privé (même avec extractable: false) et que vous avez besoin du CryptoKey public correspondant, appelez crypto.subtle.getPublicKey() :

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

3. Encapsuler et désencapsuler une clé partagée

encapsulateKey() et decapsulateKey() importent le secret partagé KEM de 32 octets (256 bits) dans un CryptoKey à l'aide de l'algorithme spécifié dans sharedKeyAlgorithm (au format 'raw-secret'). Transmettez 'ChaCha20-Poly1305' ou 'AES-GCM' directement pour produire une clé de chiffrement 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'],
);

Séparation des domaines avec 'HKDF'

Pour lier la clé partagée à un contexte de protocole spécifique (info et salt) ou dériver plusieurs sous-clés à partir d'une seule encapsulation, transmettez 'HKDF' en tant que sharedKeyAlgorithm avec l'utilisation ['deriveKey'], puis appelez 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. Chiffrer et déchiffrer des charges utiles ('ChaCha20-Poly1305' et 'AES-GCM')

Chiffrez les données de la charge utile avec un algorithme AEAD ('ChaCha20-Poly1305' ou 'AES-GCM') à l'aide d'un vecteur d'initialisation (iv) de 12 octets (96 bits) récent et aléatoire généré par chiffrement pour chaque opération de chiffrement. Ne réutilisez jamais une paire (iv, key). Choisissez 'ChaCha20-Poly1305' pour des performances constantes sur tous les appareils sans accélération matérielle AES dédiée, ou 'AES-GCM' pour l'interopérabilité avec les déploiements WebCrypto ou FIPS 140 existants.

  • 'ChaCha20-Poly1305' : utilise une clé fixe de 256 bits (32 octets) (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt']) accepte une chaîne ou { name: 'ChaCha20-Poly1305' } sans propriété length). Le iv doit comporter exactement 12 octets, et le tag d'authentification Poly1305 de 128 bits est ajouté au texte chiffré.
  • 'AES-GCM' : transmettez { name: 'AES-GCM', length: 256 } lors de l'appel de generateKey() ou deriveKey(), et un iv de 12 octets lors de l'appel de encrypt() et decrypt().
  • additionalData (AAD) : liez des métadonnées non chiffrées (telles qu'un ID d'enregistrement, un ID de destinataire ou une version de protocole) au tag d'authentification via additionalData afin que toute modification du texte chiffré ou des métadonnées entraîne le rejet de decrypt() avec 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);
}

Stratégies de remplacement

Si votre cible Baseline n'est pas encore compatible avec SubtleCrypto.supports(), encapsulateKey() ou 'ChaCha20-Poly1305', détectez la compatibilité à l'aide de la méthode statique SubtleCrypto.supports() et utilisez l'accord de clé Diffie-Hellman éphémère-statique ('X25519' + 'HKDF' + 'AES-GCM', ou 'ECDH' avec 'P-256' lorsque vous ciblez des navigateurs antérieurs à la compatibilité avec 'X25519') :

  • Appelez SubtleCrypto.supports() sur le constructeur SubtleCrypto, et non sur crypto.subtle : globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305') est une méthode statique synchrone qui renvoie une valeur booléenne (crypto.subtle.supports est undefined). Notez que SubtleCrypto.supports() valide les paramètres spécifiques à l'opération : les chaînes d'algorithme brutes fonctionnent pour 'encapsulateKey', 'decapsulateKey', 'generateKey', 'importKey', 'exportKey' et 'getPublicKey', tandis que l'analyse directe de 'encrypt' ou 'decrypt' nécessite un iv (par exemple, SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })).
  • Format de clé dans la solution de secours ('raw') : bien que les navigateurs compatibles avec Modern WebCrypto acceptent également 'raw-public' sur 'X25519' et 'ECDH', utilisez 'raw' dans les chemins de secours ciblant les anciens navigateurs.
  • Solution de remplacement classique ('X25519' + 'HKDF' + 'AES-GCM') : lorsque SubtleCrypto.supports() n'est pas disponible ou renvoie false, générez une paire de clés 'X25519' éphémère pour l'expéditeur, exportez la clé publique éphémère en tant qu'octets de capsule, dérivez 256 bits via deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256) et transmettez ces bits via 'HKDF' ('SHA-256') pour dériver une clé 'AES-GCM' de 256 bits.
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);
}