# Chiffrer ne suffit pas : encore faut-il savoir où

> AES-GCM empêche quiconque de forger ou d'altérer vos données chiffrées. Il n'empêche pas de déplacer une donnée chiffrée ailleurs, ou elle sera naturellement déchiffrée. Une attaque concrète, le paramètre qui la corrige, un test qui la garde corrigée, et ce qu'il reste à protéger ensuite.

Source: https://www.bluewise.fr/fr/aes-gcm-aad-ciphertext-swapping  
Author: Marc Morel  
Published: 2026-10-21  
Category: Engineering

Si votre équipe chiffre ses champs sensibles avec AES-GCM, vous avez fait un bon choix : c’est rapide, accéléré matériellement et *authentifié*. Modifiez un seul bit d’un chiffré, et le déchiffrement échoue totalement.

C’est justement autour de ce mot que naît un malentendu discret. On entend « personne ne peut altérer mes données ». La promesse réelle est plus étroite : **sans la clé, personne ne peut produire un chiffré qui se déchiffre**. 

> Rien, dans cette promesse, ne dit que le chiffré se trouve *au bon endroit*. 

Quelqu’un qui peut écrire dans votre base n’a rien à forger : vous stockez déjà des millions de chiffrés valides, qui ne demandent qu’à être déplacés.
Un petit test avant d’aller plus loin : ouvrez votre fonction de chiffrement. Si sa signature est `Encrypt(value)`, sans rien qui précise *ce qui* est chiffré ni *pour qui*, cet article est pour vous.

## Ce que promet vraiment AES-GCM

AES-GCM est un schéma AEAD, *Authenticated Encryption with Associated Data* (chiffrement authentifié avec données associées) :

| Entrée | Secrète ? | Stockée ? | Rôle |
| --- | --- | --- | --- |
| Clé | Oui | Non : KMS, HSM ou coffre à secrets | Tout en dépend |
| Nonce, 96 bits | Non | Oui, à côté du chiffré | Ne doit jamais se répéter sous la même clé |
| Texte clair | Oui | Non | La valeur que vous protégez |
| Données associées (AAD) | Non | En général non : recalculées depuis le contexte | Liées au tag, jamais chiffrées |

Le déchiffrement exige la même clé, le même nonce et les mêmes AAD, faute de quoi la vérification du tag de 128 bits échoue. Autrement dit :

> Un chiffré ne se déchiffre qu’avec la clé exacte et les AAD exactes qui ont servi à le produire.

Relisez cette phrase en laissant les AAD vides. Chiffrez tout sous une même clé sans AAD, et chaque chiffré devient valide *partout*.

## L’IBAN qui changea de propriétaire

Imaginez un service ordinaire qui stocke les IBAN de ses clients, chiffrés dans l’application, avec des clés primaires UUIDv7 :

```sql
CREATE TABLE customers (
  id         UUID   PRIMARY KEY DEFAULT uuidv7(),  -- PostgreSQL 18+
  tenant_id  UUID   NOT NULL,
  iban_enc   BYTEA  NOT NULL                        -- nonce || chiffré || tag
);
```

Le code Go est idiomatique et, du point de vue d’AES-GCM, correct :

```go
type Cipher struct{ aead cipher.AEAD }

// New attend une clé de 32 octets (AES-256), en pratique déballée par un KMS.
func New(key []byte) (*Cipher, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	// Go 1.24+ : Seal génère un nonce aléatoire de 96 bits et le place en tête ; Open le relit.
	aead, err := cipher.NewGCMWithRandomNonce(block)
	if err != nil {
		return nil, err
	}
	return &Cipher{aead: aead}, nil
}

func (c *Cipher) Encrypt(plaintext []byte) []byte {
	return c.aead.Seal(nil, nil, plaintext, nil) // pas d'AAD
}

func (c *Cipher) Decrypt(blob []byte) ([]byte, error) {
	return c.aead.Open(nil, nil, blob, nil)
}
```

Voici deux clients du même *tenant* : Alice (`018e5126-…-8af1`) et Mallory (`01a06432-…-780e`).

Imaginez maintenant quelqu’un qui peut écrire dans la table sans avoir jamais vu la clé : une injection limitée à `UPDATE`, un outil de back-office avec des droits d’édition bruts, un job ETL compromis, une sauvegarde altérée. Exactement l’adversaire contre lequel le chiffrement au niveau des champs existe. Une seule requête suffit :

```sql
UPDATE customers
SET    iban_enc = (SELECT iban_enc FROM customers
                   WHERE id = '01a06432-6760-7b90-9f79-70f30255780e')  -- l'IBAN de Mallory
WHERE  id = '018e5126-7a60-754f-b7a3-da1c3f128af1';                     -- dans la ligne d'Alice
```

Aucune alerte. Le prochain remboursement d’Alice part sur l’IBAN de Mallory ; faites l’échange dans l’autre sens, et la page « Mon compte » de Mallory affiche celui d’Alice. Le tag est valide dans les deux cas.

Rendons justice à AES-GCM : il a fait beaucoup. L’attaquant n’a pu ni choisir, ni modifier, ni tronquer un IBAN. Il ne lui restait qu’un seul geste : **déplacer des valeurs qui existent déjà**. Dans le paiement, c’est largement suffisant.

*[Figure: L’IBAN chiffré d’Alice est copié dans la ligne de Mallory]*

Rien de tout cela n’est nouveau : la documentation de Google Cloud KMS parle d’attaque du *confused deputy*. Les cryptographes la connaissent bien ; les équipes qui écrivent les fonctions `Encrypt()`, beaucoup moins.

## La solution : donner une adresse à chaque chiffré

Les AAD indiquent, au moment du chiffrement, *à quel endroit appartient un chiffré*. Elles ne sont pas secrètes, mais elles entrent dans le calcul du tag : déchiffrer avec d’autres AAD échoue. On lie donc chaque valeur à son tenant, sa table, sa colonne et sa ligne :

```go
// ErrAuthentication : chiffré altéré, ou déchiffré hors de son contexte. À traiter comme une alerte.
var ErrAuthentication = errors.New("fieldcrypt: authentication failed")

// Context identifie la cellule exacte à laquelle appartient une valeur.
type Context struct {
	TenantID uuid.UUID
	Table    string
	Column   string
	RowID    uuid.UUID
}

const formatV1 byte = 1

// AAD : une version de format, puis des champs préfixés par leur longueur. Les UUID en 16 octets bruts.
func (c Context) AAD() []byte {
	aad := []byte{formatV1}
	for _, f := range [][]byte{c.TenantID[:], []byte(c.Table), []byte(c.Column), c.RowID[:]} {
		aad = binary.BigEndian.AppendUint32(aad, uint32(len(f)))
		aad = append(aad, f...)
	}
	return aad
}

func (c *Cipher) Encrypt(plaintext []byte, ctx Context) []byte {
	return c.aead.Seal(nil, nil, plaintext, ctx.AAD())
}

func (c *Cipher) Decrypt(blob []byte, ctx Context) ([]byte, error) {
	plaintext, err := c.aead.Open(nil, nil, blob, ctx.AAD())
	if err != nil {
		return nil, ErrAuthentication
	}
	return plaintext, nil
}
```

`Context` est obligatoire, et l’erreur sentinelle permet aux appelants de repérer une valeur déplacée avec `errors.Is`.

Rejouons l’attaque. Le chiffré de Mallory a été scellé pour `acme / customers / iban / …780e` ; le déchiffrer pour Alice reconstruit les AAD avec `…8af1`, et `Decrypt` renvoie `ErrAuthentication`. Le chiffré peut toujours être déplacé, mais il ne s’ouvre plus que là où il a été scellé, pour le prix de quelques octets hachés en plus et de rien de stocké en plus.

Toutes les API AEAD courantes proposent ce paramètre ; il est simplement facultatif, et c’est pour cela qu’on l’oublie :

| Écosystème | Où passer les AAD |
| --- | --- |
| Go | `additionalData` dans `Seal` / `Open` |
| Java (JCA) | `cipher.updateAAD(aad)` |
| Python (`cryptography`) | `associated_data` dans `AESGCM.encrypt` / `decrypt` |
| Node.js | `setAAD(aad)` |
| .NET | `associatedData` dans `AesGcm.Encrypt` / `Decrypt` |
| PHP | `$aad` dans `openssl_encrypt`, ou `$additional_data` avec libsodium |
| Google Tink | `associatedData` dans `Aead.encrypt` / `decrypt` |
| AWS KMS | l’*encryption context* |
| Google Cloud KMS | `additionalAuthenticatedData` |

Si vous maintenez une bibliothèque de chiffrement interne, faites de `Encrypt(value, ctx)` la seule porte d’entrée.

## Six règles pour bien utiliser les AAD

**1. Partir de la question, pas de la réponse.** Construisez les AAD à partir de ce que l’application *attend* : pour rembourser `…8af1`, le contexte vient de la requête, pas d’une colonne de la ligne même que l’attaquant peut modifier. S’il contrôle à la fois le chiffré et les éléments des AAD, il peut les faire concorder.

**2. N’utiliser que des identifiants immuables.** Les clés UUIDv7 et les identifiants de tenant, oui ; les e-mails ou les noms affichés, non, sinon l’IBAN d’Alice devient indéchiffrable le jour où elle change d’adresse. Méfiez-vous des identifiants immuables… jusqu’au jour où une table est renommée, un tenant fusionné ou des identifiants régénérés lors d’une migration : chacune de ces opérations impose désormais un rechiffrement.

**3. Encoder sans ambiguïté, et de façon canonique.** Une concaténation naïve `tenant + "|" + row` permet à une valeur contenant `|` d’entrer en collision avec une autre. Préfixez chaque champ par sa longueur, commencez par une version de format et utilisez les 16 octets bruts des UUID : il suffit qu’un service les écrive en majuscules ou sans tirets pour rendre des données valides indéchiffrables.

**4. Tenir les données personnelles à l’écart.** Les AAD sont authentifiées, pas chiffrées : le jour où elles apparaissent dans une ligne de log, elles sont en clair.

**5. Authentifier l’identifiant de clé et la version de format.** Chaque chiffré devrait porter un en-tête indiquant sa clé et son format, inclus dans les AAD. Cela évite le *déchiffrement par essais successifs* pendant les rotations de clés, une pratique fragile : AES-GCM n’est pas *key-committing*, c’est-à-dire qu’un chiffré peut être fabriqué pour se déchiffrer sous plusieurs clés. C’est l’origine des attaques par « partitioning oracle » publiées en 2021.

**6. Traiter un échec d’authentification comme un événement de sécurité.** Bug, corruption ou échange : il mérite une alerte, pas un log de débogage et un champ vide.

## Un test pour que ça tienne

Les propriétés cryptographiques régressent en silence. Un test piloté par table capture le modèle de menace :

```go
func TestCiphertextIsBoundToItsCell(t *testing.T) {
	c := newTestCipher(t)
	home := Context{acmeID, "customers", "iban", malloryID}
	blob := c.Encrypt([]byte("FR76 1027 8060 0000 0204 5670 142"), home)

	if _, err := c.Decrypt(blob, home); err != nil {
		t.Fatalf("decrypt in place: %v", err)
	}

	elsewhere := map[string]Context{
		"another customer": {acmeID, "customers", "iban", aliceID},
		"another tenant":   {globexID, "customers", "iban", malloryID},
		"another column":   {acmeID, "customers", "phone", malloryID},
	}
	for name, ctx := range elsewhere {
		t.Run(name, func(t *testing.T) {
			if _, err := c.Decrypt(blob, ctx); !errors.Is(err, ErrAuthentication) {
				t.Fatalf("want ErrAuthentication, got %v", err)
			}
		})
	}
}
```

Ajoutez-lui son grand frère : créez deux clients via votre repository, échangez leur `iban_enc` en SQL brut, et vérifiez que le chargement de l’un comme de l’autre échoue. Ce test-là protège le *câblage*, là où la règle 1 se gagne ou se perd.

## Ce que les AAD ne corrigent pas

**Le retour arrière dans une même cellule.** L’ancien et le nouvel IBAN d’Alice partagent les mêmes AAD : une valeur restaurée depuis la sauvegarde de l’an dernier se déchiffre toujours. Un compteur de version dans les AAD n’aide que si l’attaquant ne peut pas le faire revenir en arrière lui aussi ; une piste d’audit ou une réconciliation CDC détecte généralement mieux ce cas.

**Une autorisation défaillante.** Si Mallory peut appeler `GET /customers/018e5126-…-8af1`, l’application déchiffre légitimement les données d’Alice. UUIDv7 est ordonné dans le temps et en partie prévisible : ce n’a jamais été un mécanisme de contrôle d’accès.

**Une clé qui fuit.** Les AAD sont publiques. Elles limitent ce qu’un attaquant peut faire *sans* la clé, pas ce qu’il peut faire *avec*.

## Combien coûte une seule défaillance ?

Avec une seule clé pour tout, la compromission de cette clé est totale. Ce sera le sujet du prochain article de cette série ; en voici les grandes lignes :

- **Chiffrement d’enveloppe :** une *clé de chiffrement de données* (DEK) par tenant ou par client, protégée par une *clé de chiffrement de clés* (KEK) qui ne quitte jamais le KMS ou le HSM. Même réflexe que dans [Three turtles might be enough](https://www.bluewise.fr/three-turtles-might-be-enough) : une racine de confiance qu’on peut utiliser, mais pas copier.
- **Crypto-shredding :** détruisez la clé d’un client, et toutes les copies de ses données deviennent illisibles, sauvegardes comprises.
- **Un budget de nonces qui n’est plus partagé :** la NIST SP 800-38D limite une clé à 2³² chiffrements avec nonce aléatoire, car un seul nonce répété révèle le XOR de deux textes clairs et permet de forger des tags. `NewGCMWithRandomNonce` masque la gestion des nonces, pas cette limite.
- **Moins d’endroits capables de déchiffrer :** si chaque service peut déballer chaque clé, un seul service compromis expose tout.

| Attaque | Une clé, sans AAD | Une clé + AAD | Une clé par tenant + AAD | Une clé par client + AAD + déchiffrement restreint |
| --- | --- | --- | --- | --- |
| Déplacer une valeur entre clients | Réussit | Bloquée | Bloquée | Bloquée |
| Déplacer une valeur entre tenants | Réussit | Bloquée | Bloquée deux fois | Bloquée deux fois |
| Revenir à une ancienne valeur | Réussit | Réussit | Réussit | Détectable via la piste d’audit |
| Fuite de clé depuis une instance | Toutes les données | Toutes les données | Un tenant | Les clés déballées par cette instance |
| Effacer un client, sauvegardes comprises | Non | Non | Non | Détruire sa clé |

La deuxième colonne coûte quelques lignes de code ; les suivantes demandent un vrai travail d’ingénierie, et méritent une décision délibérée.

## Migrer un système existant

1. **Versionner le format :** préfixez chaque chiffré par un octet de format et un identifiant de clé, tous deux inclus dans les AAD.
2. **Lire l’ancien, écrire le nouveau :** acceptez le format 0 en lecture, écrivez toujours en format 1.
3. **Rechiffrer en tâche de fond** les valeurs au format 0.
4. **Supprimer la lecture du format 0,** l’étape que tout le monde oublie. Tant qu’elle existe, un ancien chiffré tiré d’une sauvegarde contourne votre liaison. La migration est terminée quand ce chemin a disparu, pas quand le rechiffrement atteint 100 %.

## Ce qui change

> Une checklist pour votre prochaine revue de conception

- Chaque appel de chiffrement porte-t-il un contexte, sans aucune API permettant de l’omettre ?
- Le contexte est-il construit à partir de ce que l’application *a demandé*, et non à partir du stockage ?
- Les champs des AAD sont-ils immuables, encodés de façon canonique et exempts de données personnelles ?
- L’identifiant de clé est-il authentifié, sans aucun déchiffrement par essais successifs ?
- Un échec d’authentification déclenche-t-il une alerte ?
- Existe-t-il un test d’échange, au niveau unitaire comme au niveau intégration ?
- Un retour arrière est-il détectable par autre chose que le chiffrement ?
- Si une clé fuit demain, savez-vous exactement quelles données sont exposées, et à qui elles appartiennent ?

Le chiffrement authentifié est une promesse sur *qui* a produit une valeur. Les AAD en font une promesse sur l’endroit *où* cette valeur a sa place. Si votre `Encrypt` ne prend encore qu’un seul argument, vous tenez une excellente première pull request pour cette semaine.
