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 :
Encaps ne prend aucun message en entrée. Elle produit une clé symétrique tirée au
hasard et un chiffré qui permet au détenteur de de la retrouver. On chiffre ensuite
les vraies données avec 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 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 pseudo-aléatoire de taille sur , et deux vecteurs courts et . Publier , garder .
Chiffrement de . Tirer , , courts, et calculer
Déchiffrement. Calculer et arrondir chaque coefficient au multiple de le plus proche.
Le calcul décisif tient en deux lignes :
Le message est encodé en plaçant chaque bit soit près de 0, soit près de . Le terme d'erreur est petit ; tant qu'il reste sous , l'arrondi retombe sur le bon bit. Tout le dimensionnement de ML-KEM consiste à garder ce terme d'erreur sous .
La compression, et le bruit qu'elle achète
Publier et avec 12 bits par coefficient donnerait des chiffrés inutilement gros. On les compresse en jetant des bits de poids faible :
La décompression rend une valeur approchée, à 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 : et ne sont pas compressés au même taux. garde 10 ou 11 bits, seulement 4 ou 5. Ce n'est pas une inconséquence — ne transporte qu'un bit de message par coefficient, décodé en comparant à , donc une erreur de cent unités y est sans conséquence. Chaque terme est compressé selon ce qu'il transporte.
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 :
- déchiffrer pour obtenir ;
- dériver l'aléa à partir de lui-même — le chiffrement devient déterministe ;
- rechiffrer avec , obtenant ;
- si , renvoyer la clé ; sinon, renvoyer une clé bidon dérivée d'un secret interne.
Le test prouve que a réellement été produit par la procédure d'encapsulation. Un chiffré fabriqué ne peut pas passer, puisque l'attaquant devrait deviner à 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-512 | ML-KEM-768 | ML-KEM-1024 | |
|---|---|---|---|
| rang | 2 | 3 | 4 |
| niveau NIST | 1 | 3 | 5 |
| clé publique | 800 o | 1184 o | 1568 o |
| clé privée | 1632 o | 2400 o | 3168 o |
| chiffré | 768 o | 1088 o | 1568 o |
| secret partagé | 32 o | 32 o | 32 o |
| échec de déchiffrement |
Notez ce que la table ne contient pas : ni , ni , ni l'anneau. Ils sont identiques pour les trois niveaux. Seul 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 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 , un bit est mal décodé et la clé obtenue est fausse.
Les probabilités affichées ci-dessus — de l'ordre de — 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 . 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.
Pourquoi ML-KEM renvoie-t-il une clé bidon plutôt qu'une erreur quand la ré-encapsulation échoue ?
À vous
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.
// 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`); }
À retenir
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.