cursus.

Cours 2 · Réseaux euclidiensLeçon 3 sur 4

ML-KEM (Kyber)

5 h de lecture8 sections Version PDF

À la fin de cette leçon, vous saurez

Du PKE au KEM IND-CCA2 par Fujisaki-Okamoto : compression, échantillonnage, jeux 512/768/1024, tailles et taux d'échec de déchiffrement.

ML-KEM est normalisé par la FIPS 203 et déployé aujourd'hui dans les navigateurs. C'est le schéma post-quantique que vos étudiants rencontreront le plus sûrement en production. Ce chapitre le construit brique par brique, en partant d'un objet que le chapitre précédent a rendu presque évident.

Un KEM, et pourquoi pas un chiffrement

Première clarification, souvent sautée. ML-KEM ne chiffre pas de messages. C'est un mécanisme d'encapsulation de clé — trois algorithmes :

KeyGen()(ek,dk)Encaps(ek)(c,K)Decaps(dk,c)K\begin{aligned} \mathrm{KeyGen}() &\to (ek, dk) \\ \mathrm{Encaps}(ek) &\to (c, K) \\ \mathrm{Decaps}(dk, c) &\to K \end{aligned}

Encaps ne prend aucun message en entrée. Elle produit une clé symétrique KK tirée au hasard et un chiffré cc qui permet au détenteur de dkdk de la retrouver. On chiffre ensuite les vraies données avec KK et un AEAD.

Pourquoi cette forme plutôt qu'un chiffrement à clé publique ordinaire ? Parce qu'elle est plus simple à sécuriser. Un KEM n'a pas à gérer de bourrage, ni de messages de taille variable, ni de messages choisis par l'attaquant. Sa seule obligation est que KK soit indistinguable d'une clé aléatoire. En pratique, c'est aussi la seule chose dont les protocoles ont besoin : TLS, SSH et les autres veulent une clé de session, pas un chiffrement asymétrique de charge utile.

Le chiffrement sous-jacent

Sous ML-KEM se trouve un chiffrement à clé publique, appelé K-PKE, qui est du Module-LWE à peine déguisé.

Génération. Tirer AA pseudo-aléatoire de taille k×kk \times k sur RqR_q, et deux vecteurs courts ss et ee. Publier t=As+et = As + e, garder ss.

Chiffrement de mm. Tirer rr, e1e_1, e2e_2 courts, et calculer

u=ATr+e1,v=tTr+e2+q/2mu = A^{T} r + e_1, \qquad v = t^{T} r + e_2 + \left\lceil q/2 \right\rfloor \cdot m

Déchiffrement. Calculer vsTuv - s^{T} u et arrondir chaque coefficient au multiple de q/2q/2 le plus proche.

Le calcul décisif tient en deux lignes :

vsTu=(eTr+e2sTe1)petit+q/2mv - s^{T}u = \underbrace{(e^{T}r + e_2 - s^{T}e_1)}_{\text{petit}} + \left\lceil q/2 \right\rfloor \cdot m

Le message est encodé en plaçant chaque bit soit près de 0, soit près de q/2q/2. Le terme d'erreur est petit ; tant qu'il reste sous q/4q/4, l'arrondi retombe sur le bon bit. Tout le dimensionnement de ML-KEM consiste à garder ce terme d'erreur sous q/4q/4.

La compression, et le bruit qu'elle achète

Publier uu et vv avec 12 bits par coefficient donnerait des chiffrés inutilement gros. On les compresse en jetant des bits de poids faible :

Compressd(x)=2dqxmod2d\mathrm{Compress}_d(x) = \left\lceil \frac{2^d}{q} \cdot x \right\rfloor \bmod 2^d

La décompression rend une valeur approchée, à q/2d+1q/2^{d+1} près. Cette erreur d'arrondi s'ajoute au budget de bruit — c'est pour cela que la compression n'est pas gratuite, et qu'on ne peut pas compresser autant qu'on voudrait.

Le point à faire remarquer en cours : uu et vv ne sont pas compressés au même taux. uu garde 10 ou 11 bits, vv seulement 4 ou 5. Ce n'est pas une inconséquence — vv ne transporte qu'un bit de message par coefficient, décodé en comparant à q/2q/2, donc une erreur de cent unités y est sans conséquence. Chaque terme est compressé selon ce qu'il transporte.

Quiz · vérifiez votre compréhension Sans réponse

Pourquoi la compression de ML-KEM n'est-elle pas sans coût de sécurité ?

De IND-CPA à IND-CCA2 : Fujisaki-Okamoto

Le K-PKE ci-dessus est IND-CPA, et seulement IND-CPA. Il est malléable : un attaquant qui modifie un chiffré valide obtient un chiffré qui se déchiffre encore, en un message différent mais lié. Contre un attaquant capable de soumettre des chiffrés au déchiffrement, c'est fatal.

La transformation de Fujisaki-Okamoto, dans sa variante avec rejet implicite, corrige cela avec une idée simple. On ré-encapsule :

  1. déchiffrer cc pour obtenir mm' ;
  2. dériver l'aléa rr' à partir de mm' lui-même — le chiffrement devient déterministe ;
  3. rechiffrer mm' avec rr', obtenant cc' ;
  4. si c=cc' = c, renvoyer la clé ; sinon, renvoyer une clé bidon dérivée d'un secret interne.

Le test c=cc' = c prouve que cc a réellement été produit par la procédure d'encapsulation. Un chiffré fabriqué ne peut pas passer, puisque l'attaquant devrait deviner mm' à l'avance.

Le point délicat est la dernière ligne. On ne renvoie pas d'erreur : on renvoie une clé pseudo-aléatoire, fausse mais indistinguable d'une vraie. C'est le rejet implicite. Dire « échec » créerait un oracle : l'attaquant apprendrait quels chiffrés sont valides, et c'est exactement ce dont il a besoin. Le chapitre 11 en donne la preuve, et le chapitre 12 montre comment une implémentation bavarde rouvre l'oracle par le temps d'exécution.

Les trois jeux de paramètres

ML-KEM-512ML-KEM-768ML-KEM-1024
rang kk234
niveau NIST135
clé publique800 o1184 o1568 o
clé privée1632 o2400 o3168 o
chiffré768 o1088 o1568 o
secret partagé32 o32 o32 o
échec de déchiffrement21392^{-139}21642^{-164}21742^{-174}

Notez ce que la table ne contient pas : ni nn, ni qq, ni l'anneau. Ils sont identiques pour les trois niveaux. Seul kk change, avec les taux de compression. C'est le bénéfice d'implémentation du Module-LWE annoncé au chapitre précédent.

Le total échangé dans un handshake, clé publique plus chiffré. ML-KEM-768 coûte 2272 octets contre 64 pour X25519 : un facteur 35. C'est cher, mais cela reste quelques kilooctets — la comparaison avec McEliece, au chapitre 8, remettra cette facture en perspective.

Le taux d'échec de déchiffrement

ML-KEM peut échouer. C'est déroutant pour qui vient de RSA, et c'est structurel : si le terme de bruit dépasse q/4q/4, un bit est mal décodé et la clé obtenue est fausse.

Les probabilités affichées ci-dessus — de l'ordre de 21642^{-164} — rendent l'événement inobservable : il ne se produira jamais, sur aucun parc, pendant aucune durée. Mais elles ne sont pas nulles, et cela a deux conséquences qu'il faut énoncer.

D'abord, ce taux est un paramètre de sécurité, pas seulement de fiabilité. Un attaquant capable de provoquer des échecs, en soumettant des chiffrés spécialement construits, apprendrait de l'information sur le secret — chaque échec est une inégalité satisfaite par ss. C'est l'attaque par échec de déchiffrement, et c'est pourquoi le taux doit être astronomiquement bas plutôt que simplement négligeable en pratique.

Ensuite, cela impose que la ré-encapsulation FO soit implémentée en temps constant, échec compris. Une implémentation qui met plus de temps sur un échec que sur un succès restaure exactement l'oracle qu'on cherchait à fermer.

Quiz · vérifiez votre compréhension Sans réponse

Pourquoi ML-KEM renvoie-t-il une clé bidon plutôt qu'une erreur quand la ré-encapsulation échoue ?

À vous

Exercice · JavaScript · à vous de jouer

Implémentez Compress et Decompress, puis vérifiez que l'erreur mesurée colle à la borne q/2^(d+1) — et comprenez pourquoi du et dv ne sont pas égaux.

En attente
// La compression de ML-KEM : jeter des bits de poids faible pour réduire
// le chiffré, en acceptant l'erreur que cela introduit.

const q = 3329;

// Compress_d : ramène un élément de Z_q sur d bits.
function compresser(x, d) {
  // À COMPLÉTER — arrondir (2^d / q) · x, puis réduire modulo 2^d.
  return 0;
}

// Decompress_d : retour approximatif vers Z_q.
function decompresser(y, d) {
  // À COMPLÉTER — arrondir (q / 2^d) · y.
  return 0;
}

// Écart centré dans Z_q : 3328 est à distance 1 de 0.
const ecart = (a, b) => {
  const d = Math.abs(a - b) % q;
  return Math.min(d, q - d);
};

console.log("d | bits/coef | erreur max | erreur moyenne | borne q/2^(d+1)");
for (const d of [12, 11, 10, 5, 4, 3]) {
  let max = 0, total = 0;
  for (let x = 0; x < q; x++) {
    const e = ecart(x, decompresser(compresser(x, d), d));
    max = Math.max(max, e);
    total += e;
  }
  console.log(
    `${String(d).padStart(2)} | ${String(d).padStart(9)} | ${String(max).padStart(10)}` +
    ` | ${(total / q).toFixed(2).padStart(14)} | ${(q / 2 ** (d + 1)).toFixed(1).padStart(15)}`
  );
}

// Taille du chiffré ML-KEM : 32 · (du·k + dv) octets.
console.log("\nchiffré ML-KEM selon (du, dv) :");
for (const [nom, k, du, dv] of [["512", 2, 10, 4], ["768", 3, 10, 4], ["1024", 4, 11, 5]]) {
  console.log(`  ML-KEM-${nom.padEnd(4)} k=${k} du=${du} dv=${dv}${32 * (du * k + dv)} octets`);
}

Console de sortie
Le résultat s'affiche dans la console

À retenir

Flashcards · 1 / 3Toucher pour retourner
Fin de la leçon

Vous avez parcouru les 8 sections.

Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.