corentin_wakdo/docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md
Corentin JOGUET ad5203d3fc
All checks were successful
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 20s
CI / static-tests (push) Successful in 32s
CI / auto-merge (push) Has been skipped
feat(admin): throttle du PIN d action sensible par acteur (RG-T22) (#18)
2026-06-16 00:06:33 +02:00

13 KiB

P3 securite — throttle du PIN d'action sensible (RG-T22), design multi-agents + verification adversariale

Date : 2026-06-15 (suite de la session CRUD Produits #17) Branche : working tree sur dev (chunk non commite ; base dev = 2756fb4) PR : ouverte vers dev apres revue de l'implementation (auto-merge sur CI verte) Duree estimee : session longue (finalisation + merge Produits, puis design + build + docs Merise du throttle)


Ce qui a ete fait

Deux temps dans la session.

1. Finalisation et merge du CRUD Produits (PR #17)

Le CRUD produits (cas riche : price_cents, vat_rate {55,100}, category_id, suppression FK-safe) a ete termine, revu (6 findings : 1 HIGH, 1 LOW, 4 MEDIUM de couverture), corrige, puis merge sur dev en auto-merge sur CI verte (squash, dev = 2756fb4). La revue avait remonte un finding HIGH : le PIN d'action sensible (PinVerifier) verifie le PIN avec parite de timing mais sans limitation de tentatives. Mitigation shippee dans #17 : chaque echec ecrit une ligne audit_log pin.failed (detectable). Le throttle complet a ete arbitre comme chunk dedie — ce qui suit.

2. Throttle du PIN (RG-T22) — conception puis construction

Conception via un panel multi-agents (3 lentilles independantes : Ockham / efficacite-menace / anti-DoS) -> synthese -> passe adversariale. Le panel a tranche la dimension du compteur et a integre deux correctifs d'emblee. Verdict de l'adversaire : la conception tient (holds = true).

Artefacts produits (tous dans le working tree, non commites) :

  • db/migrations/0002_pin_throttle.sql — nouvelle table (entite 22), cle sur actor_user_id (UNIQUE, FK -> user ON DELETE CASCADE), separee des compteurs de connexion. Appliquee a la base dev via bash db/migrate.sh.
  • src/app/Auth/ThrottlePolicy.php — dimension 'pin' ajoutee a fromConfig (bornes propres PIN_THROTTLE_* : base 30s, plafond 300s).
  • src/app/Auth/PinThrottle.php (nouveau) — isLocked / recordFailure (upsert atomique + backoff, une transaction) / reset.
  • src/app/Auth/PinVerifier.php — methode additive payTimingDecoy (parite de timing du chemin verrouille).
  • src/app/Controllers/ProductController.php — cablage dans update (branche prix/TVA) et destroy : gate avant verification, recordFailure sur PIN faux, reset apres l'effet reussi.
  • Config : .env.example + docker-compose.yml (PIN_THROTTLE_THRESHOLD/BASE/MAX/WINDOW).
  • Docs Merise portees de 21 a 22 entites : RG-T22 dans mlt.md, entite 22 pin_throttle dans mcd.md / mld.md / dictionary.md, couverture MCT 22/22 dans mct.md.
  • Tests : +16 (dimension pin de ThrottlePolicy ; PinThrottleTest ; cas de controleur ; leurre de timing ; integration PinThrottleDbTest). 188 tests / 525 assertions verts, PHPStan L6 propre.

Pourquoi — decisions et alternatives

Decision 1 — Compter les echecs par utilisateur AGISSANT (et non par email cible ni par IP)

  • Decision : la dimension du throttle est l'identite de session authentifiee qui realise l'action ($guard->userId), stockee dans une table dediee pin_throttle cle sur actor_user_id.
  • Alternatives considerees :
    • par email cible : contournable par rotation des emails (le modele "identifiant equipier + PIN" verifie un email arbitraire) ;
    • par IP : sur un poste a session partagee, tous les equipiers sortent par la meme IP ; un verrou IP priverait de re-autorisation l'ensemble des equipiers honnetes du comptoir ;
    • hybride cible + IP avec delai usleep : ajoute une colonne de portee, ~6 cles de config, un usleep qui retient un worker PHP-FPM, et une surface de blocage d'un collegue ;
    • globale : un seul attaquant degraderait l'autorisation sensible de tout le magasin.
  • Raison du choix : la cle "acteur" est la seule non-contournable (changer d'acteur impose une reconnexion, elle-meme throttlee et auditee cote login) ET sans collateral sur un poste partage (verrouiller l'attaquant n'affecte aucun autre user_id). Elle dissout la tension rotation/collateral qui force les autres pistes a un delai par IP. Rasoir d'Ockham (#37) : une table, un collaborateur, deux points d'appel, PinVerifier inchange.

Decision 2 — Table dediee, separee des compteurs de connexion

  • Decision : compteurs pin_throttle physiquement distincts de user.failed_login_attempts / user.lockout_until / login_throttle.
  • Alternative : reutiliser les colonnes de login existantes.
  • Raison : un echec de PIN n'incremente aucun compteur de login ; sinon, marteler le PIN d'une victime verrouillerait sa connexion (escalade de deni de service vers une surface plus sensible). Un test de regression verifie l'absence d'ecriture vers user/login_throttle sur le chemin d'echec.

Decision 3 — Backoff plus permissif que le login

  • Decision : base 30s, plafond 300s (le login est a 60s / 900s).
  • Raison : RG-T13 cadre le PIN comme un controle de dissuasion (risque residuel Faible) ; un faux positif bloque un manager en plein rush. Le backoff reste degressif, pas un verrou definitif.

Decision 4 — Correctifs adversariaux integres a la conception (pas en second passage)

  • Anti-flood de l'audit : sous verrou actif, aucune nouvelle ligne pin.failed (les echecs ayant arme le verrou sont deja audites) — sinon le chemin verrouille, moins couteux, gonflerait le journal append-only et noierait l'alerte de volume.
  • Parite de timing : payTimingDecoy paie le cout argon2id sur le chemin verrouille, pour que la latence ne distingue pas "verrouille" de "mauvais PIN".

Methodo — pourquoi un panel + une passe adversariale

Challenge Before Confirm (mantra IA-16) sur un finding de severite HIGH avec migration de schema (peu reversible) : faire produire trois conceptions independantes, les arbitrer, puis tenter de casser la retenue. La passe adversariale a confirme que les quatre attaques visees (rotation d'email, falsification de X-Forwarded-For, contamination du compteur de login, collateral de borne partagee) echouent par construction, et a remonte les deux correctifs ci-dessus.


Comment — points techniques cles

  • Upsert atomique, miroir de la dimension IP d'AuthService : INSERT ... ON DUPLICATE KEY UPDATE failed_attempts = IF(window_started_at < :cutoff, 1, failed_attempts + 1) .... L'increment est calcule cote SQL sous le verrou de ligne pris sur la cle UNIQUE, ce qui serialise les POST concurrents (anti lost-update). Placeholders nommes distincts car PDO::ATTR_EMULATE_PREPARES = false interdit de lier un meme nom deux fois (src/app/Auth/PinThrottle.php).
  • Gate-before-verify : isLocked($actorId) est evalue AVANT resolveActingUser. Un acteur verrouille recoit le meme 422 generique "Email ou PIN invalide" (anti-enumeration) ; meme un PIN correct est bloque tant que le verrou court.
  • Le piege du reset : a un succes, deux identites sont en portee — l'acteur de session ($guard->userId, celui qui a ete incremente) et l'equipier resolu par le PIN ($actor['id'], ecrit dans audit_log). Le reset cible l'acteur de session ; le confondre laisserait le compteur de l'agissant sans purge. Un test l'asserte explicitement (ProductControllerTest).
  • FK ON DELETE CASCADE (contrairement a login_throttle, sans FK) : la cle est un utilisateur back-office authentifie, donc supprimer/anonymiser le compte retire proprement sa ligne de throttle (etat ephemere, par opposition a audit_log qui est permanent et en SET NULL).

Criteres RNCP couverts

  • Bloc 2 - Cr 3.a / 3.b : extension du modele Merise (dictionnaire/MCD/MLD) — entite 22 pin_throttle, FK et cardinalite (assoc R9), coherence 22/22 verifiee dans les quatre docs.
  • Bloc 2 - Cr 4.e (securite) : requetes preparees (anti-injection), reponse generique (anti-enumeration), separation dure des compteurs (anti escalade de DoS), gate avant verification.
  • Bloc 2 - Cr 4.c (POO / namespaces) : PinThrottle (classe dediee), reutilisation de ThrottlePolicy (math pure), cablage via les controleurs heritant d'AdminController.
  • Bloc 2 - Cr 4.g (preparation livraison) : 188 tests PHPUnit verts, PHPStan niveau 6 propre, test d'integration contre une vraie MariaDB.
  • Bloc 2 - Cr 3.d (RGPD) : FK ON DELETE CASCADE (l'etat de throttle suit l'anonymisation du compte) et purge cron documentee (minimisation / limitation de conservation).
  • Bloc 5 - Cr 7.b.3 (cron) / Cr 7.d.2 (tests avant deploiement) : predicat de purge pin_throttle aligne sur login_throttle ; le chunk passera la CI (PHPUnit + PHPStan + secret-scan) avant merge.

Questions anticipees du jury

  • Q : "Pourquoi compter les echecs de PIN sur l'utilisateur agissant plutot que sur l'IP, comme pour le login ?" R : Sur une borne a session partagee, tous les equipiers sortent par la meme IP ; un verrou par IP les priverait tous de re-autorisation. La cle "acteur" verrouille seulement l'individu qui multiplie les echecs, sans toucher ses collegues, et reste non-contournable (changer d'acteur impose une reconnexion, deja throttlee cote login).

  • Q : "Un attaquant qui martele le PIN d'un collegue peut-il bloquer sa connexion ?" R : Non. Les compteurs du PIN vivent dans une table separee (pin_throttle), distincte de user.failed_login_attempts et de login_throttle. Un echec de PIN n'ecrit aucun compteur de login ; un test de regression le verifie.

  • Q : "Pourquoi un backoff degressif et pas un verrou definitif ?" R : Le PIN est un controle de dissuasion a risque residuel Faible ; un verrou dur bloquerait un manager sur quelques fautes de frappe en plein service. Le backoff ralentit la force brute (de quelques essais a une poignee par fenetre) tout en s'auto-resorbant.

  • Q : "Comment avez-vous valide cette conception de securite ?" R : Trois conceptions independantes ont ete produites puis arbitrees, et une passe adversariale a tente de casser la retenue (rotation d'email, falsification d'en-tete proxy, contamination du login, collateral de borne). Les quatre echouent par construction ; la passe a aussi remonte deux correctifs (anti-flood de l'audit, parite de timing) integres avant la fin.

  • Q : "Pourquoi ajouter une 22e table plutot que des colonnes sur user ?" R : Des colonnes sur user devraient porter sur l'utilisateur cible (contournable par rotation) ou ajouter une 4e dimension de verrou sur la table de comptes. Une table dediee, cle sur l'acteur, garde user epuree et garantit la separation des compteurs par construction.


Points d'amelioration conscients

  • Couverture CI de l'increment SQL : les tests unitaires stubbent le compteur relu apres l'upsert (FakeDatabase.pinThrottleAttempts fixe), donc la semantique reelle de l'increment + fenetre glissante n'est prouvee que par PinThrottleDbTest (integration), auto-skippee sans MariaDB. C'est la posture STANDARD du projet (CI sans Composer ni base : AuthServiceDbTest, PinVerifierDbTest... skippent de meme) ; verifiee en local avec WAKDO_DB_TESTS=1. A garder en tete si la CI gagne un service DB.
  • Cron de purge non encore etendu : le predicat de purge pin_throttle est documente (mlt.md 13.5) mais le job cron lui-meme (docker/cron) n'a pas ete edite. Sans impact fonctionnel (la table tient une ligne par utilisateur back-office) ; a brancher avec le job login_throttle existant.
  • Dimension par IP volontairement absente : choix documente (collateral de borne partagee). A reconsiderer seulement si un abus par IP est observe en pratique.
  • Detection : l'alerte sur le volume de pin.failed est le vrai controle detectif ; elle reste a outiller cote supervision (hors code applicatif). Un PIN de plus de 4 chiffres pour les roles sensibles est recommande.

Etat a la reprise

  • Chunk throttle PIN complet (source + tests + migration + docs Merise + .env.example + compose + ce journal), vert (188 tests, PHPStan L6), revue adversariale de l'implementation passee (holds = true), commite et pousse cette session avec PR vers dev (auto-merge sur CI verte). Migration 0002 deja appliquee a la base dev.
  • Prochaine action : suite P3 : Menus (+ slots), Ingredients/stock, Users + matrice RBAC, Stats. Differe : etendre le cron de purge a pin_throttle ; alerte de volume pin.failed (supervision).

Liens vers artefacts

  • CRUD Produits merge : commit 49ab77b -> dev 2756fb4 (PR #17, squash).
  • Throttle PIN (non commite) : src/app/Auth/PinThrottle.php, src/app/Auth/ThrottlePolicy.php, src/app/Auth/PinVerifier.php, src/app/Controllers/ProductController.php, db/migrations/0002_pin_throttle.sql.
  • Tests : tests/Unit/Auth/PinThrottleTest.php, tests/Unit/Auth/ThrottlePolicyTest.php, tests/Unit/Admin/ProductControllerTest.php, tests/Integration/PinThrottleDbTest.php, tests/Support/FakeDatabase.php.
  • Docs Merise (RG-T22, entite 22) : docs/merise/{mlt,mcd,mld,dictionary,mct}.md.
  • Config : .env.example, docker-compose.yml (PIN_THROTTLE_*).
  • Resume roulant : docs/SESSION_RESUME.md (entree Produits #17 = suite 4).