Cryptographie post-quantique · C4 Sécurité et implémentation · Chapitre 2 · 6 h
Canaux auxiliaires et implémentation
Temps, consommation, fautes ; attaques sur l'échantillonnage gaussien et le rejet ; masquage, temps constant, oracles de déchiffrement sur les KEM.
Le chapitre précédent s'est terminé sur une limite : aucun jeu de sécurité ne donne à l'adversaire un chronomètre, une sonde de courant ou un laser. C'est pourtant par là que passent la majorité des attaques réelles. Un schéma parfaitement prouvé peut tomber en quelques minutes sur une carte à puce, et cela n'invalide en rien la preuve — cela montre qu'elle parlait d'autre chose.
Ce chapitre est le plus long du cours. Ce n'est pas un hasard : c'est là que se joue la sécurité effective des déploiements.
Le modèle change
Les modèles du chapitre 11 traitent l'implémentation comme une boîte noire : l'adversaire soumet des entrées et lit des sorties. Un circuit réel n'est pas une boîte noire. Il met un certain temps, consomme un certain courant, émet un certain rayonnement, et peut être perturbé.
Chacune de ces grandeurs est une sortie supplémentaire, non prévue par la spécification, et souvent corrélée aux valeurs secrètes manipulées. C'est tout le domaine des canaux auxiliaires, ouvert par Kocher en 1996 avec les attaques temporelles, puis en 1999 avec l'analyse différentielle de consommation.
Animation · 9 étapes
Comparer deux étiquettes : ce que la sortie anticipée révèle
- L'étiquette soumise par l'attaquant — Une seule position diffère de l'attendue : la troisième, 199 au lieu de 200. L'attaquant ne le sait pas encore — c'est ce qu'il cherche à découvrir.
- Octet 0 : égal — 17 = 17, la boucle continue.
- Octet 1 : égal — 42 = 42. Deuxième tour de boucle.
- Octet 2 : différent, sortie immédiate — 199 ≠ 200 : la fonction renvoie « faux » sans lire les cinq octets restants. Le résultat est correct. Le problème est ailleurs.
- Ce que le chronomètre a dit — Le temps d'exécution est proportionnel à la longueur du préfixe correct. L'attaquant fait varier l'octet 2 sur ses 256 valeurs, garde celle qui prend un tour de plus, et recommence. Il forge l'étiquette en 256 × 8 essais au lieu de 2⁶⁴.
- Version en temps constant : on continue — Au lieu de sortir, on accumule : diff |= reçu[i] XOR attendu[i]. La différence est enregistrée, mais la boucle ne s'interrompt pas.
- On lit tout, même quand c'est inutile — Le résultat est déjà connu depuis l'octet 2. On continue quand même : c'est du travail gaspillé, et c'est précisément ce qu'on achète.
- Huit octets, toujours — La boucle lit les huit octets quelle que soit l'entrée. Le verdict se lit à la fin sur diff, sans branchement dépendant du secret.
- Le temps ne dit plus rien — Même durée pour toutes les entrées : le canal temporel est refermé. Retenez la règle générale — aucun branchement, aucun accès mémoire et aucune boucle ne doivent dépendre d'une valeur secrète.
L'animation compare deux fonctions qui font la même chose et donnent le même résultat. La première sort dès qu'elle trouve une différence ; la seconde lit tout. Cette différence d'écriture, qui paraîtrait un détail d'optimisation à toute relecture ordinaire, sépare essais de 2048.
Le temps d'exécution
C'est le canal le plus accessible — il se mesure à distance, à travers un réseau — et le plus fréquent en pratique.
La règle est simple à énoncer et exigeante à respecter : aucun branchement, aucun accès mémoire indexé et aucune borne de boucle ne doivent dépendre d'une valeur secrète.
Les trois cas se valent en gravité. Un branchement fait varier le nombre d'instructions. Un accès mémoire indexé par un secret — une table de substitution, par exemple — fait varier l'état du cache, et un attaquant qui partage le processeur peut l'observer. Une borne de boucle secrète est le cas de l'animation.
Le point le plus contre-intuitif est qu'une division peut fuir. Sur beaucoup de processeurs, le temps de la division entière dépend des opérandes. C'est exactement ce qui s'est produit avec KyberSlash, signalée fin 2023 : l'implémentation de référence de ML-KEM contenait une division par dont le temps dépendait d'une valeur secrète, ce qui permettait de reconstruire la clé. Le schéma était prouvé, la spécification correcte, l'implémentation officielle. Le code fuyait.
Pire encore : écrire du code sans branchement ne suffit pas si le compilateur en réintroduit. Des cas documentés montrent un optimiseur transformant une expression écrite sans branchement — précisément pour éviter la fuite — en un saut conditionnel, parce que c'est plus rapide. La vérification doit donc porter sur le binaire produit, pas seulement sur la source. C'est une exigence que peu d'équipes appliquent.
Quiz · 1 question
Quelle règle résume les contre-mesures temporelles ?
- Ajouter un délai aléatoire à chaque opération sensible
- Aucun branchement, accès mémoire indexé ni borne de boucle ne doit dépendre d'un secret
- Chiffrer les valeurs intermédiaires en mémoire
Réponse : Un délai aléatoire ne fait qu'ajouter du bruit : l'attaquant moyenne sur plus de mesures et retrouve le signal — cela augmente le coût de l'attaque d'un facteur, cela ne la supprime pas. Chiffrer la mémoire ne change rien au temps d'exécution. La seule contre-mesure qui ferme le canal est structurelle : le flot d'exécution et les adresses accédées doivent être identiques pour toutes les valeurs secrètes.
Consommation, rayonnement, fautes
Sur un composant auquel l'attaquant a un accès physique — carte à puce, module de sécurité, objet connecté — trois autres canaux s'ouvrent.
L'analyse simple de consommation (SPA) lit le déroulement de l'algorithme sur une seule trace de courant. Les motifs sont souvent visibles à l'œil nu sur l'oscilloscope.
L'analyse différentielle (DPA) est bien plus puissante. Elle corrèle des milliers de traces avec une hypothèse sur un fragment de clé : la bonne hypothèse fait apparaître un pic de corrélation. Elle fonctionne même quand le signal est très inférieur au bruit, parce que la statistique accumule.
Les attaques par faute perturbent le calcul — variation de tension, impulsion laser, horloge dégradée — et exploitent le résultat erroné. Sur une signature déterministe, comparer une exécution correcte et une exécution fautée du même message livre le secret : c'est la raison pour laquelle la FIPS 204 fait de la signature aléatoire le mode par défaut, comme vu au chapitre 7.
Ce qui est propre à la cryptographie post-quantique
Un cours qui se contenterait des généralités ci-dessus manquerait l'essentiel. Les schémas à réseaux offrent des prises que RSA et les courbes elliptiques n'avaient pas.
L'échantillonnage. Tirer du bruit est une opération secrète, et elle est bien plus
complexe qu'un simple random(). L'échantillonneur gaussien de Falcon a été attaqué à
plusieurs reprises, et des attaques par cache visant l'échantillonnage gaussien de schémas
antérieurs sont documentées depuis 2016. C'est précisément pour cela que ML-KEM emploie une
binomiale centrée, qui se calcule en comptant des bits.
Le rejet d'échantillonnage. Le nombre de tours de ML-DSA est aléatoire et corrélé au secret : c'est justement parce que dépasse le seuil qu'on rejette. Un signataire dont le temps total laisse voir ce nombre rend une information exploitable. L'implémentation doit donc masquer le nombre de tours, ce qui n'est pas trivial quand il est intrinsèquement variable.
L'oracle d'échec de déchiffrement. Le rejet implicite du chapitre 6 ne ferme l'oracle que si les deux branches — succès et échec — sont indiscernables en temps comme en consommation. Une implémentation qui court-circuite le calcul de la clé bidon quand la ré-encapsulation réussit rouvre exactement le canal qu'on croyait fermé.
Le masquage coûte plus cher qu'ailleurs. La contre-mesure de référence consiste à partager chaque valeur secrète en plusieurs parts aléatoires, de sorte qu'aucune part seule ne corrèle au secret. Le problème est que les schémas à réseaux alternent de l'arithmétique modulaire — la NTT — et des opérations booléennes — hachage, compression, encodage. Or le masquage arithmétique et le masquage booléen ne sont pas compatibles : il faut convertir de l'un à l'autre, et ces conversions sont coûteuses. Un ML-KEM masqué à l'ordre 2 ou 3 est plusieurs fois plus lent que sa version nue.
Quiz · 1 question
Pourquoi le masquage est-il plus coûteux pour ML-KEM que pour AES ?
- Parce que les clés sont plus grosses
- Parce que le schéma alterne arithmétique modulaire (NTT) et opérations booléennes (hachage, compression), et que convertir entre masquage arithmétique et booléen est coûteux
- Parce que le masquage doit être appliqué à un plus grand nombre de tours
Réponse : AES est purement booléen : un seul type de masquage suffit. ML-KEM enchaîne des multiplications modulaires dans la NTT — qui appellent un masquage arithmétique — et du hachage, de la compression, de l'encodage — qui appellent un masquage booléen. Les deux ne se composent pas : il faut des conversions A2B et B2A à chaque frontière, et ce sont elles qui dominent le surcoût.
Que faire, concrètement
Quatre recommandations, dans l'ordre où elles doivent être appliquées.
Ne réimplémentez pas. Utilisez les implémentations éprouvées — celles qui sont auditées, testées en temps constant et maintenues. Écrire soi-même un ML-KEM correct est un projet de plusieurs mois-personnes, et l'exercice de ce chapitre montre à quel point la faute est facile.
Vérifiez le binaire. Des outils d'analyse dynamique détectent les branchements et accès mémoire dépendant d'entrées marquées comme secrètes. Intégrez-les à l'intégration continue, pas à une revue ponctuelle : une mise à jour de compilateur peut réintroduire une fuite dans un code inchangé.
Dimensionnez les contre-mesures selon le modèle de menace. Le masquage et la redondance contre les fautes ne se justifient que si l'attaquant a un accès physique. Pour un serveur en centre de données, le temps constant suffit et le reste est du gaspillage.
Testez les fautes si le matériel est exposé. Une carte à puce ou un objet connecté déployé sur le terrain doit être évalué en injection, pas seulement en analyse statique.
À vous
L'exercice remplace le chronomètre par un compteur d'octets lus — l'horloge d'un navigateur est trop grossière pour une mesure fiable, mais le principe est identique. Le rapport entre les deux colonnes est la totalité du chapitre.
Exercice de code
Écrivez la boucle de forge octet par octet, puis lancez-la contre les deux implémentations : 2048 requêtes contre 2^64.
Point de départ
// Forger une étiquette d'authentification de 8 octets sans connaître le
// secret, uniquement en observant le TEMPS de vérification.
//
// Mesurer des nanosecondes dans un navigateur n'est pas fiable : l'horloge
// y est volontairement grossière. On remplace donc le chronomètre par un
// COMPTEUR d'octets lus, qui est ce que le chronomètre mesurerait. Le
// principe de l'attaque est identique ; seule la métrologie est simplifiée.
const ETIQUETTE_SECRETE = [0x8f, 0x2a, 0xc7, 0x10, 0x55, 0xe3, 0x9b, 0x04];
let octetsLus = 0;
// La victime : comparaison naïve, avec sortie anticipée.
function verifierNaif(soumise) {
for (let i = 0; i < ETIQUETTE_SECRETE.length; i++) {
octetsLus++;
if (soumise[i] !== ETIQUETTE_SECRETE[i]) return false;
}
return true;
}
// La même, en temps constant.
function verifierConstant(soumise) {
let diff = 0;
for (let i = 0; i < ETIQUETTE_SECRETE.length; i++) {
octetsLus++;
diff |= soumise[i] ^ ETIQUETTE_SECRETE[i];
}
return diff === 0;
}
// L'oracle : combien d'octets la victime a-t-elle lus ?
function mesurer(verifier, soumise) {
octetsLus = 0;
const ok = verifier(soumise);
return { ok, cout: octetsLus };
}
let requetes = 0;
function forger(verifier) {
requetes = 0;
const trouvee = new Array(8).fill(0);
for (let position = 0; position < 8; position++) {
// À COMPLÉTER — pour chaque valeur d'octet de 0 à 255, mesurer le coût
// et retenir celle qui en provoque le PLUS : c'est la bonne, puisque
// la boucle est allée un cran plus loin.
trouvee[position] = 0;
}
return trouvee;
}
const hex = (t) => t.map((b) => b.toString(16).padStart(2, "0")).join(" ");
console.log("secret réel :", hex(ETIQUETTE_SECRETE));
const contreNaif = forger(verifierNaif);
console.log("contre le naïf :", hex(contreNaif),
verifierNaif(contreNaif) ? "→ ACCEPTÉE" : "→ refusée", `(${requetes} requêtes)`);
const contreConstant = forger(verifierConstant);
console.log("contre le constant :", hex(contreConstant),
verifierConstant(contreConstant) ? "→ ACCEPTÉE" : "→ refusée", `(${requetes} requêtes)`);
console.log("\nforce brute nécessaire : 2^" + (8 * 8));
Solution
const ETIQUETTE_SECRETE = [0x8f, 0x2a, 0xc7, 0x10, 0x55, 0xe3, 0x9b, 0x04];
let octetsLus = 0;
function verifierNaif(soumise) {
for (let i = 0; i < ETIQUETTE_SECRETE.length; i++) {
octetsLus++;
if (soumise[i] !== ETIQUETTE_SECRETE[i]) return false;
}
return true;
}
function verifierConstant(soumise) {
let diff = 0;
for (let i = 0; i < ETIQUETTE_SECRETE.length; i++) {
octetsLus++;
diff |= soumise[i] ^ ETIQUETTE_SECRETE[i];
}
return diff === 0;
}
function mesurer(verifier, soumise) {
octetsLus = 0;
const ok = verifier(soumise);
return { ok, cout: octetsLus };
}
let requetes = 0;
function forger(verifier) {
requetes = 0;
const trouvee = new Array(8).fill(0);
for (let position = 0; position < 8; position++) {
let meilleur = -1, meilleurCout = -1;
for (let valeur = 0; valeur < 256; valeur++) {
requetes++;
const essai = trouvee.slice();
essai[position] = valeur;
// Les positions au-delà de la position courante restent à zéro : peu importe,
// la boucle de la victime s'arrêtera avant si l'octet courant est
// faux, et exactement un cran plus loin s'il est juste.
const { cout } = mesurer(verifier, essai);
if (cout > meilleurCout) { meilleurCout = cout; meilleur = valeur; }
}
trouvee[position] = meilleur;
}
return trouvee;
}
const hex = (t) => t.map((b) => b.toString(16).padStart(2, "0")).join(" ");
console.log("secret réel :", hex(ETIQUETTE_SECRETE));
const contreNaif = forger(verifierNaif);
console.log("contre le naïf :", hex(contreNaif),
verifierNaif(contreNaif) ? "→ ACCEPTÉE" : "→ refusée", `(${requetes} requêtes)`);
const contreConstant = forger(verifierConstant);
console.log("contre le constant :", hex(contreConstant),
verifierConstant(contreConstant) ? "→ ACCEPTÉE" : "→ refusée", `(${requetes} requêtes)`);
console.log("\nforce brute nécessaire : 2^" + (8 * 8));
// Trois chiffres résument le chapitre.
//
// 2048 requêtes contre la version naïve — 256 valeurs × 8 positions — et
// l'étiquette est retrouvée EXACTEMENT. Pas approchée : retrouvée.
//
// 2^64 requêtes sans le canal auxiliaire. Le rapport est de l'ordre de
// 10^16.
//
// 0 information contre la version en temps constant : le coût est de 8
// octets lus quelle que soit l'entrée, la boucle de forge retient donc la
// première valeur essayée, et l'étiquette produite est fausse.
//
// La différence entre les deux fonctions tient en une ligne : un « return »
// anticipé contre un OU cumulé. C'est la totalité de la contre-mesure, et
// c'est la totalité de la faille.
//
// Deux remarques pour la pratique.
//
// D'abord, cette faille existe encore. KyberSlash, signalée fin 2023, était
// une division par q non constante en temps dans l'implémentation de
// référence de ML-KEM. Le schéma était prouvé sûr ; le code fuyait.
//
// Ensuite, écrire du code en temps constant ne suffit pas : il faut que le
// COMPILATEUR le préserve. Des cas documentés montrent un compilateur
// reconstituant un branchement conditionnel à partir d'une expression
// écrite sans branchement, au nom de l'optimisation. La vérification doit
// donc porter sur le binaire produit, pas seulement sur la source.
À retenir
Flashcards · 3 cartes
- Pourquoi un schéma prouvé IND-CCA2 peut-il tomber en quelques minutes sur une carte à puce ?
- Parce que les jeux de sécurité traitent l'implémentation comme une boîte noire : entrées, sorties, rien d'autre. Un circuit réel émet aussi du temps, du courant et du rayonnement, et peut être perturbé. Ces sorties supplémentaires sont souvent corrélées au secret. La preuve borne une classe d'attaques, pas les attaques.
- Quelles prises la cryptographie à réseaux offre-t-elle que RSA n'offrait pas ?
- L'échantillonnage du bruit (l'échantillonneur gaussien de Falcon a été attaqué plusieurs fois), le rejet d'échantillonnage de ML-DSA dont le nombre de tours est corrélé au secret, et l'oracle d'échec de déchiffrement que le rejet implicite ne ferme que si les deux branches sont indiscernables. À quoi s'ajoute un masquage plus coûteux, faute de compatibilité entre masquage arithmétique et booléen.
- Pourquoi vérifier la source ne suffit-il pas pour le temps constant ?
- Parce que le compilateur peut réintroduire un branchement à partir d'une expression écrite sans branchement, au nom de l'optimisation — des cas sont documentés. La vérification doit porter sur le binaire produit, et être intégrée à la CI : une simple mise à jour de compilateur peut rouvrir une fuite dans un code inchangé. KyberSlash, fin 2023, rappelle que même l'implémentation de référence peut fuir.
QCM du bloc III — Sécurité et implémentation
Huit questions sur ce qu'une preuve garantit et sur ce qu'elle laisse ouvert. Les distracteurs y sont plus retors qu'ailleurs, parce que les erreurs de ce bloc sont des erreurs de raisonnement, pas de mémoire.
QCM de bloc · 8 questions
Sécurité et implémentation
1. Un schéma seulement IND-CPA est déployé face à un attaquant qui peut soumettre des chiffrés au déchiffrement.
- Il reste sûr : IND-CPA implique IND-CCA2
- Il n'offre aucune garantie : IND-CPA ne modélise qu'un attaquant passif
- Il reste sûr tant que les messages échangés sont courts
Réponse : L'implication va dans l'autre sens : IND-CCA2 implique IND-CPA, jamais l'inverse. Le jeu IND-CPA ne donne aucun oracle de déchiffrement à l'adversaire, donc ne dit rien de ce qui arrive quand il en obtient un. Le K-PKE de ML-KEM est IND-CPA et totalement malléable : ajouter q/2 à un coefficient inverse un bit du message.
2. Pourquoi une preuve dans le ROM classique ne suffit-elle pas pour un schéma post-quantique ?
- Parce que SHA-3 doit être remplacé par une fonction de hachage post-quantique
- Parce que le ROM suppose un adversaire de puissance seulement polynomiale
- Parce que l'attaquant peut interroger le hachage en superposition, ce qui invalide l'échantillonnage paresseux, l'extraction et le rembobinage
Réponse : SHA-3 n'a pas besoin d'être remplacé — Grover ne fait que diviser sa sécurité par deux. Le problème est méthodologique : l'attaquant connaît le code du hachage, peut l'implémenter en circuit quantique et l'interroger sur une superposition de toutes les entrées. Le réducteur ne peut alors plus noter les requêtes ni les extraire, parce qu'observer perturbe et qu'un état quantique ne se clone pas.
3. Le rejet implicite de Fujisaki-Okamoto renvoie une clé bidon plutôt qu'une erreur parce que :
- une erreur constituerait un oracle de validité, alors qu'une clé bidon déterministe ne se distingue pas d'une vraie
- l'interface de programmation d'un KEM ne prévoit pas de canal d'erreur
- cela évite d'avoir à recalculer la ré-encapsulation
Réponse : La ré-encapsulation a déjà eu lieu — c'est elle qui a détecté la fraude. Et rien n'empêcherait techniquement de renvoyer une erreur. C'est un choix de sécurité : dire « ce chiffré est invalide » apprend à l'attaquant lesquelles de ses modifications passent, ce qui est exactement l'information qu'une attaque CCA exploite. La clé bidon, dérivée d'un secret interne et du chiffré, est fausse mais indistinguable.
4. Le niveau de sécurité NIST 3 est défini par équivalence avec :
- la recherche de collision sur SHA-256
- la recherche de clé sur AES-256
- la recherche de clé sur AES-192
Réponse : Les cinq niveaux se lisent : 1 = clé AES-128, 2 = collision SHA-256, 3 = clé AES-192, 4 = collision SHA-384, 5 = clé AES-256. Le niveau 3 — ML-KEM-768 et ML-DSA-65 — est la recommandation par défaut, et c'est ce que les navigateurs déploient. Cette échelle évite d'avoir à trancher entre modèles de coût et transporte automatiquement les hypothèses sur le matériel quantique.
5. On ajoute un délai aléatoire à une comparaison dont le temps d'exécution dépend d'une valeur secrète.
- Le canal temporel est refermé
- On ajoute du bruit que l'attaquant élimine en moyennant : le coût de l'attaque monte, la faille demeure
- C'est la contre-mesure recommandée par les guides d'implémentation
Réponse : Un délai aléatoire est du bruit additif indépendant du signal : répéter la mesure et moyenner le fait disparaître. L'attaque coûte davantage de requêtes, elle ne devient pas impossible. La seule contre-mesure qui ferme réellement le canal est structurelle — le flot d'exécution et les adresses accédées doivent être identiques pour toutes les valeurs secrètes.
6. Que rappelle l'incident KyberSlash ?
- Que l'implémentation de référence d'un schéma prouvé peut fuir — ici par une division non constante en temps
- Que la spécification FIPS 203 contient une erreur de conception
- Que ML-KEM ne doit pas être employé sur processeur embarqué
Réponse : La spécification était correcte et le schéma prouvé : c'est le CODE qui fuyait, par une division par q dont le temps dépendait d'une valeur secrète sur beaucoup de processeurs. Le défaut a été corrigé, et ML-KEM s'emploie sans difficulté en embarqué. La leçon est que la sécurité prouvée et la sécurité effective sont deux propriétés distinctes, la seconde vivant dans l'implémentation.
7. Pourquoi le masquage coûte-t-il plus cher pour ML-KEM que pour AES ?
- Parce que les clés de ML-KEM sont beaucoup plus grosses
- Parce que le schéma alterne arithmétique modulaire (NTT) et opérations booléennes, imposant des conversions entre les deux types de masquage
- Parce que ML-KEM comporte davantage de tours qu'AES
Réponse : AES est purement booléen : un seul type de masquage suffit. ML-KEM enchaîne des multiplications modulaires dans la NTT — masquage arithmétique — et du hachage, de la compression, de l'encodage — masquage booléen. Les deux ne se composent pas, et ce sont les conversions A2B et B2A à chaque frontière qui dominent le surcoût.
8. Votre code ne comporte aucun branchement dépendant d'une valeur secrète. Est-ce suffisant ?
- Oui : c'est précisément la définition du temps constant
- Non : il faut en outre chiffrer les valeurs intermédiaires en mémoire
- Non : le compilateur peut réintroduire un branchement, la vérification doit porter sur le binaire
Réponse : Chiffrer la mémoire ne change rien au temps d'exécution. Le vrai problème est que la source n'est pas ce qui s'exécute : des cas documentés montrent un optimiseur transformant une expression écrite sans branchement — précisément pour éviter la fuite — en un saut conditionnel, parce que c'est plus rapide. La vérification doit porter sur le binaire, et être intégrée à la CI.