AES-GCM en Go : la limite de 2^32 messages est un commentaire, pas un contrôle

Sommaire (8 parties)

Je lis Serious Cryptography (Jean-Philippe Aumasson, No Starch Press, 2e édition, 2025) pour en tirer une fiche de lecture. Le chapitre sur AES-GCM, le chiffrement authentifié le plus utilisé selon le livre (p. 164), m'arrête sur une phrase.

AES-GCM chiffre un message avec une clé secrète et lui joint une étiquette, un sceau qui prouve qu'on ne l'a pas modifié. À chaque message, on lui donne aussi un nonce : un nombre qui ne doit jamais servir deux fois avec la même clé. La phrase du livre : « AES-GCM's biggest weakness is its fragility in the face of nonce repetition » (p. 166). Sa pire faiblesse est sa fragilité quand un nonce se répète.

Je code en Go. Dans la documentation de crypto/cipher, je lis qu'une clé ne doit pas chiffrer plus de 2^32 messages. Cela se lit « 2 puissance 32 » : 32 fois le nombre 2, multipliés entre eux, soit un peu plus de quatre milliards. J'appelle cette limite le budget de la clé. Si on tire les nonces au hasard, l'un d'eux finit par se répéter : le budget sert à rendre ce risque minuscule. Je cherche où Go le vérifie. Dans le code que j'ai lu, nulle part. J'ai donc écrit le garde-fou : un fichier de 93 lignes, testé.

Cet article répond à trois questions. Pourquoi un nonce répété est-il si grave ? Pourquoi le budget est-il 2^32, alors qu'un nonce tiré au hasard ne risque vraiment de se répéter que vers 2^48 messages ? Et que vérifie Go ?

$ git clone https://github.com/ohugonnot/gcm-nonce-budget
$ cd gcm-nonce-budget && go test -race ./...

ohugonnot/gcm-nonce-budget

Un petit morceau de code Go qui chiffre en AES-GCM avec un nonce tiré au hasard. Il refuse de chiffrer quand la clé a déjà servi pour 2^32 messages. Chaque ligne ci-dessous dit ce qui est prouvé, et par quoi.

  • VérifiéRéutiliser un nonce révèle ce que deux messages ont de différent : TestNonceReuseLeaksXorOfPlaintexts, sur le vrai AES-GCM de la bibliothèque standard.
  • VérifiéLe garde-fou refuse de chiffrer au-delà du budget, même quand 16 chiffrements ont lieu en même temps : TestBudgetRefusesAfterLimit, TestBudgetHoldsUnderConcurrency.
  • VérifiéLes tests échouent si on casse volontairement le code de trois façons : ./mutations.sh (essai du 3 novembre 2026).
  • Non vérifiéLe budget réel de 2^32 messages : le test règle un budget de 3 messages, il ne chiffre pas quatre milliards de messages.
  • Non vérifiéLe total des messages est gardé en mémoire. Un redémarrage le remet à zéro, et deux programmes qui partagent une clé ne se comptent pas.

Un nonce répété trahit tes messages

AES-GCM fabrique, à partir de la clé et du nonce, une longue suite de bits : le flux de clé, noté K. Il combine K et le message par un XOR, noté ⊕. Position par position, le résultat vaut 1 si les deux bits diffèrent, et 0 s'ils sont égaux. Le chiffré vaut donc message ⊕ K.

Le XOR a deux propriétés utiles. Combiner une suite avec elle-même ne donne que des zéros : K ⊕ K = 0. Combiner une suite avec des zéros ne la change pas. Or la clé et le nonce décident seuls du flux K. Si le nonce se répète, deux messages différents reçoivent le même flux K.

Appelons P1 et P2 les deux messages en clair, C1 et C2 leurs chiffrés. Un attaquant qui intercepte C1 et C2 calcule C1 ⊕ C2, c'est-à-dire (P1 ⊕ K) ⊕ (P2 ⊕ K). Les deux flux K s'annulent. Il lui reste P1 ⊕ P2 : ce que les deux messages ont de différent. La figure 1 le dessine.

Je l'ai vérifié sur le vrai AES-GCM de Go. Le test chiffre deux virements avec la même clé et le même nonce. Le premier envoie 100 euros à Alice. Le second envoie 900 euros à Mallory, le nom d'usage de l'attaquant en cryptographie. Dans le code, aead est l'objet AES-GCM de Go et Seal chiffre. Le nonce vaut 1 pour les deux messages. La boucle compare ensuite C1 ⊕ C2 et P1 ⊕ P2 octet par octet (^ est le XOR).

	nonce := bytes.Repeat([]byte{1}, aead.NonceSize())
	p1 := []byte("virement de 100 euros a Alice...")
	p2 := []byte("virement de 900 euros a Mallory.")
	c1 := aead.Seal(nil, nonce, p1, nil)
	c2 := aead.Seal(nil, nonce, p2, nil)
	for i := range p1 {
		if c1[i]^c2[i] != p1[i]^p2[i] {
			t.Fatalf("octet %d : C1^C2 != P1^P2", i)
		}
	}

Fichier complet : noncebudget/budget_test.go, lignes 85 à 110

Le test passe. Les deux messages commencent par les mêmes douze caractères, « virement de » et l'espace qui suit. Ces octets égaux donnent zéro dans P1 ⊕ P2, donc l'attaquant voit où les messages diffèrent. S'il devine l'un des deux, il lit l'autre.

Le dégât ne s'arrête pas là. Le livre ajoute qu'avec un nonce répété, l'attaquant peut aussi fabriquer de fausses étiquettes, valides pour n'importe quel message (p. 166). Mon dépôt ne démontre pas cette seconde fuite. En 2016, des chercheurs ont cherché des serveurs web chiffrés (en HTTPS) qui répétaient leurs nonces. Ils en ont trouvé 184, dont 23 qui utilisaient toujours le nonce tout en zéros (p. 166).

Pourquoi le budget est 2^32 et non 2^48

Le plus simple est de tirer chaque nonce au hasard. Un nonce de 96 bits est une suite de 96 chiffres, 0 ou 1. Cela fait 2^96 nonces possibles, un nombre gigantesque. On pourrait croire qu'un doublon n'arrive jamais. Il arrive bien plus tôt.

C'est le paradoxe des anniversaires. Dans un groupe de 23 personnes, deux ont leur anniversaire le même jour avec une chance sur deux environ (p. 120). Il y a pourtant 365 jours possibles. Le piège : on ne compare pas une personne à une date, mais toutes les personnes entre elles, deux par deux. Le nombre de paires grandit comme le carré du nombre de personnes.

Pour les nonces, c'est pareil. Avec des nonces de n bits, un doublon devient probable après environ 2^(n/2) tirages, la racine carrée du nombre de valeurs possibles. Mon test le vérifie sur un nonce de 24 bits. Il a 16 777 216 valeurs, et pourtant le premier doublon arrive après environ 5 100 tirages. Pour 96 bits, la règle donne 2^48, soit près de 281 000 milliards de messages.

Mon petit programme de démonstration calcule le risque d'avoir au moins un doublon, pour des nonces de 64 et de 96 bits. Chaque ligne du tableau donne un nombre de messages. Un risque de 1 veut dire que le doublon est certain, et 0.393 veut dire 39 %. Le nombre 1.16e-10 se lit 0,000000000116.

messages   nonce 64 bits   nonce 96 bits
2^16       1.16e-10        2.71e-20
2^24       7.63e-06        1.78e-15
2^32       0.393           1.16e-10
2^40       1               7.63e-06
2^48       1               0.393

Code complet : cmd/demo/main.go, lancé par go run ./cmd/demo

Lis la ligne 2^48 : avec 96 bits, le risque de doublon atteint 39 %. Avec 64 bits, il atteint 39 % dès 2^32 messages. Le livre juge ce nombre de messages inacceptablement bas (p. 82).

Ce que la norme du NIST impose

Une seule répétition suffit à tout livrer, donc le risque doit rester minuscule. La norme NIST SP 800-38D, publiée par l'institut américain des normes, exige qu'il reste sous une chance sur 4,3 milliards (section 8). Pour les nonces tirés au hasard, elle en tire une règle simple : au plus 2^32 messages par clé (section 8.3).

Refaisons le calcul avec 96 bits. Un nombre n de messages fait environ n²/2 paires, et chaque paire a une chance sur 2^96 d'avoir le même nonce. Le risque vaut donc environ n²/2^97. Pour n = 2^32, cela donne 2^64/2^97 = 2^-33, soit une chance sur 8,6 milliards : c'est la ligne 2^32 du tableau, 1.16e-10. Pour n = 2^33, cela donne 2^66/2^97 = 2^-31, soit une chance sur 2,15 milliards, trop élevé. Donc 2^32 est la plus grande puissance de deux que la norme accepte. C'est le budget.

Entre le budget (2^32) et le point à 39 % (2^48), il y a 2^16, soit 65 536 fois plus de messages. Par le même calcul, un nonce de 64 bits laisserait un budget minuscule : 2^16 messages, environ 65 000. La norme ferme d'ailleurs cette porte : un nonce tiré au hasard doit avoir au moins 96 bits de hasard (section 8.2.2).

Ce que Go écrit, et ce qu'il vérifie

Depuis Go 1.24, NewGCMWithRandomNonce tire le nonce à ta place et le colle devant le chiffré. Sa documentation dit : « A given key MUST NOT be used to encrypt more than 2^32 messages ». Cela signifie qu'une clé ne doit pas dépasser le budget. Même avertissement dans l'exemple de NewGCM : « Never use more than 2^32 random nonces with a given key ».

J'ai lu le code de Go 1.26.10, de NewGCMWithRandomNonce jusqu'à la fonction interne SealWithRandomNonce. Leurs seuls contrôles sont des paniques, c'est-à-dire des arrêts du programme avec une erreur, sur des arguments invalides. Aucune de ces fonctions ne compte les messages : Go énonce le budget et ne le vérifie pas.

Il existe une autre façon de choisir le nonce : un compteur, qui vaut 0, puis 1, puis 2. Il ne se répète jamais tant qu'un seul programme l'incrémente. Go en utilise un pour TLS 1.3, le protocole qui chiffre les connexions web, quand la connexion emploie AES-GCM. Mais il ne vérifie que le compteur avance bien que dans un mode réservé aux environnements certifiés, appelé FIPS 140-3.

Le garde-fou : un total qui ne dépasse jamais le budget

Mon code range la clé dans une structure qui garde le total des messages déjà chiffrés. À chaque chiffrement, il réserve une place dans le budget, puis tire le nonce. Quand le budget est épuisé, il refuse. Dans l'extrait, s.used est ce total et s.budget le budget.

	for {
		u := s.used.Load()
		if u >= s.budget {
			return nil, ErrBudgetExhausted
		}
		if s.used.CompareAndSwap(u, u+1) {
			break
		}
	}

Fichier complet : noncebudget/budget.go, lignes 58 à 75

La boucle lit le total. Si le budget est atteint, elle refuse. Sinon, elle tente de passer le total de u à u+1. Cette opération, dite comparaison-échange (CompareAndSwap en Go), ne réussit que si aucune autre tâche ne l'a modifié entre-temps. Une tâche est un travail qui s'exécute en même temps que les autres. Sans cette opération, deux tâches pourraient lire la même valeur, réserver la même place et dépasser le budget. Le test lance 16 tâches en parallèle qui visent 1 000 messages : exactement 1 000 passent, jamais un de plus.

Un test qui passe ne prouve pas qu'il protège quelque chose. Le script mutations.sh casse donc le code de trois façons, une à la fois. Chaque casse volontaire s'appelle une mutation. Le script vérifie que les tests échouent.

attrapée     : le budget n'est plus vérifié
attrapée     : le nonce n'est plus tiré au hasard
attrapée     : un refus consomme quand même le budget

Un relecteur automatique, un modèle d'IA qui n'avait pas écrit le code, a relu le dépôt. Il a trouvé un défaut dans ce script. Une de mes mutations ne compilait pas : le compilateur, le programme qui transforme le code en exécutable, la refusait. Le script la comptait comme « attrapée ». Pourtant aucun test n'avait tourné, donc rien ne prouvait que les tests savent détecter cette casse. Le script refuse maintenant une mutation qui ne compile pas.

Reste ce que le dépôt ne prouve pas. Le total vit en mémoire : un service qui redémarre repart à zéro, et récupère donc 2^32 messages de plus sur la même clé. Pour un vrai service, change de clé régulièrement, bien avant le budget : c'est la rotation de clé. Ne chiffre qu'avec la clé courante et garde les anciennes pour déchiffrer. Le dépôt ne gère pas cette rotation.

Mon avis : un compteur quand tu le peux

Mon avis : si tu contrôles le programme qui envoie les messages, l'émetteur, prends un compteur. Il ne se répète jamais tant qu'un seul émetteur le tient. Pour un nonce de 96 bits construit ainsi, la norme ne fixe pas de budget de 2^32 messages. Seule la taille du compteur borne le nombre de messages (section 8.3).

Le nonce aléatoire se justifie pour un service sans état. Un tel service oublie tout entre deux exécutions, donc il ne peut pas tenir un compteur sans stockage partagé. Alors le budget et la rotation de clé deviennent obligatoires. Mon total en mémoire ne suffit pas : il faut le stocker ou changer de clé à chaque démarrage. Dans les deux cas, une limite qui ne vit que dans un commentaire de code, du texte que le programme ignore, n'est pas une limite.

Conclusion

J'ai cherché où Go vérifie le budget de 2^32, et la réponse tenait en une recherche dans le code. La documentation parle à l'humain avec un « MUST NOT » que le programme ne lit pas. Entre le point où une répétition devient probable et le budget du NIST, il y a 65 536 fois de marge. Cette marge existe pour qu'une seule erreur reste improbable, et c'est à ton code de la faire respecter.

Les livres derrière cet article

  • Serious Cryptography, 2nd Edition Jean-Philippe Aumasson, No Starch Press 2025

    Repris : la fragilité d'AES-GCM face à un nonce répété (p. 166), le paradoxe des anniversaires (p. 120), le nonce de 64 bits (p. 82). À corriger : le livre ne donne pas le budget de 2^32 messages par clé en GCM. Trois formulations cherchées sur les 380 pages ne donnent rien. Je l'ai pris dans la norme NIST.

Sources

Documentation consultée le 3 novembre 2026 :

Commentaires (0)