@nodefony/security 10.0.0-alpha.8 → 10.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/nodefony/command/security-token.js +4 -4
  5. package/dist/nodefony/command/security-user-add.js +2 -18
  6. package/dist/nodefony/command/security-user-delete.js +2 -2
  7. package/dist/nodefony/command/security-user-list.js +1 -1
  8. package/dist/nodefony/command/security-user-password.js +2 -2
  9. package/dist/nodefony/config/config.js +2 -2
  10. package/dist/nodefony/service/apiKeys.js +2 -2
  11. package/dist/nodefony/service/auditService.js +6 -5
  12. package/dist/nodefony/service/authFlow.js +2 -2
  13. package/dist/nodefony/service/authorization.js +3 -4
  14. package/dist/nodefony/service/firewall.js +21 -9
  15. package/dist/nodefony/service/oauth2.js +5 -3
  16. package/dist/nodefony/service/securityHeaders.js +1 -1
  17. package/dist/nodefony/service/tokenService.js +30 -20
  18. package/dist/nodefony/service/totp.js +5 -3
  19. package/dist/nodefony/service/webAuthn.js +41 -33
  20. package/dist/nodefony/service/webhooks.js +55 -44
  21. package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
  22. package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
  23. package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
  24. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
  25. package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
  26. package/dist/nodefony/src/audit/auditBridge.js +9 -5
  27. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
  28. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
  29. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
  30. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
  31. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
  32. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
  33. package/dist/nodefony/src/csp.js +4 -5
  34. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  35. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
  36. package/dist/nodefony/src/token/JwtKeystore.js +7 -6
  37. package/dist/nodefony/src/token/MemoryTokenStore.js +19 -6
  38. package/dist/nodefony/src/token/jwtRuntime.js +13 -1
  39. package/dist/nodefony/src/token/tokenCriteria.js +1 -0
  40. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  41. package/dist/nodefony/src/totp/totpCrypto.js +9 -9
  42. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  43. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  44. package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
  45. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  46. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +8 -4
  47. package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
  48. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
  49. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
  50. package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
  51. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
  52. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
  53. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
  54. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
  55. package/dist/types/nodefony/service/firewall.d.ts +3 -2
  56. package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
  57. package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
  58. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
  59. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
  60. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
  61. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
  62. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
  63. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
  64. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
  65. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  66. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  67. package/docs/api-keys.md +67 -65
  68. package/docs/audit.md +87 -82
  69. package/docs/authenticators.md +61 -49
  70. package/docs/authorization.md +21 -21
  71. package/docs/cors.md +30 -30
  72. package/docs/csrf.md +28 -28
  73. package/docs/external-jwt.md +21 -13
  74. package/docs/firewall.md +32 -32
  75. package/docs/headers.md +29 -29
  76. package/docs/index.md +3 -1
  77. package/docs/oauth2.md +61 -61
  78. package/docs/obtenir-un-jeton.md +2 -2
  79. package/docs/tokens.md +56 -56
  80. package/docs/totp.md +73 -72
  81. package/docs/webauthn.md +85 -85
  82. package/docs/webhooks.md +50 -47
  83. package/package.json +19 -16
package/docs/totp.md CHANGED
@@ -110,9 +110,9 @@ reste le second facteur **universel** — celui qui marche sans matériel dédi
110
110
 
111
111
  Trois partis pris, tous vérifiables dans le code.
112
112
 
113
- **1. La logique est PURE, le service n'est qu'une prise de courant.** `TotpService` (`totp.ts:71`)
113
+ **1. La logique est PURE, le service n'est qu'une prise de courant.** `TotpService` (`totp.ts:74`)
114
114
  résout au boot deux choses seulement : le **store** de secrets et la **clé de chiffrement**
115
- (`TotpService.#build()`, `totp.ts:89`). Tout le reste — enrôler, confirmer, vérifier — vit dans des
115
+ (`TotpService.#build()`, `totp.ts:92`). Tout le reste — enrôler, confirmer, vérifier — vit dans des
116
116
  fonctions sans I/O ni container (`totpOperations.ts`), qui reçoivent leurs dépendances en argument
117
117
  (`ITotpDeps`, `totpOperations.ts:22`). Conséquence directe : la logique critique se teste **sans
118
118
  serveur, sans base, avec une horloge injectée** — et c'est pour ça qu'elle est couverte à ~100 %.
@@ -125,14 +125,14 @@ différence de traitement, pas une négligence.
125
125
 
126
126
  **3. Le TOTP n'est pas un authenticator du firewall — c'est un step-up de login.** Il n'apparaît
127
127
  jamais dans `area.authenticators` : il s'insère **dans le flux de session BFF**, entre le mot de
128
- passe et l'ouverture de session (`AuthFlow.completeMfaLogin()`, `authFlow.ts:254`). Même dessin que
128
+ passe et l'ouverture de session (`AuthFlow.completeMfaLogin()`, `authFlow.ts:257`). Même dessin que
129
129
  WebAuthn et OAuth : le firewall n'a qu'un seul mécanisme à connaître, la **session**.
130
130
 
131
131
  > [!IMPORTANT]
132
132
  > Le couplage est fait **par nom de service**, jamais par import : `AuthFlow` ne connaît du 2FA
133
133
  > qu'une interface locale de trois méthodes (`ITotpLoginVerifier`, `authFlow.ts:44`). 2FA désactivé
134
134
  > ⇒ service absent ⇒ le login nominal **ne paie strictement rien** (`AuthFlow.#resolveTotp()`,
135
- > `authFlow.ts:455`).
135
+ > `authFlow.ts:458`).
136
136
 
137
137
  ## 🚀 Démarrage rapide
138
138
 
@@ -143,7 +143,7 @@ activer un second facteur depuis sa page « ma sécurité », et que le login l'
143
143
 
144
144
  Le secret TOTP est chiffré au repos → il faut une clé **stable** (sinon les secrets deviennent
145
145
  illisibles au redémarrage). La commande la génère et te dit exactement où la coller
146
- (`nodefony security:secrets`, `security-secrets.ts:36`) :
146
+ (`nodefony security:secrets`, `security-secrets.ts:79`) :
147
147
 
148
148
  ```bash
149
149
  npx nodefony security:secrets --write
@@ -154,7 +154,7 @@ npx nodefony security:secrets --write
154
154
  # 3. Fichier nodefony.config.ts — le câblage vers le module security
155
155
  ```
156
156
 
157
- Elle produit 32 octets aléatoires en base64 (`randomBytes(32)`, `security-secrets.ts:122`) et
157
+ Elle produit 32 octets aléatoires en base64 (`randomBytes(32)`, `security-secrets.ts:136`) et
158
158
  **n'écrase jamais** une valeur existante — une rotation reste un geste manuel et conscient.
159
159
 
160
160
  ### 2. Déclarer puis câbler la clé
@@ -301,7 +301,7 @@ Ce que le code garantit à chaque étape :
301
301
 
302
302
  > [!TIP]
303
303
  > Le HTTP ne laisse jamais fuir le détail : code faux, enrôlement absent, ou déjà confirmé donnent
304
- > tous le **même** `400 Invalid or expired code` (`TotpController.confirm()`, `TotpController.ts:97`).
304
+ > tous le **même** `400 Invalid or expired code` (`TotpController.confirm()`, `TotpController.ts:78`).
305
305
  > La cause fine reste côté serveur.
306
306
 
307
307
  ### La vérification au login
@@ -333,13 +333,13 @@ sequenceDiagram
333
333
  A-->>U: 200 { user }
334
334
  ```
335
335
 
336
- Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:254`) :
336
+ Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) :
337
337
 
338
338
  1. **Le défi vit en session, pas dans l'URL ni dans un jeton client** — clé `mfa:pending`
339
339
  (`authFlow.ts:18`), posée par le login, **consommée** avant l'ouverture de session
340
- (`authFlow.ts:298`).
340
+ (`authFlow.ts:301`).
341
341
  2. **Le code à 6 chiffres est throttlé** comme un mot de passe — même backoff partagé
342
- (`AuthFlow.#resolveThrottler()`, `authFlow.ts:264`) : 10⁶ combinaisons se forcent brute en
342
+ (`AuthFlow.#resolveThrottler()`, `authFlow.ts:447`) : 10⁶ combinaisons se forcent brute en
343
343
  quelques minutes sans lui. Trop de tentatives → `429` + `Retry-After`.
344
344
  3. **Un échec ne détruit pas le défi** — l'utilisateur qui s'est trompé de chiffre ressaisit ; il
345
345
  n'a pas à refaire son mot de passe.
@@ -403,13 +403,13 @@ Le store, lui, ne voit que des octets : il ne déchiffre jamais rien (`ITotpSecr
403
403
 
404
404
  ### La politique de clé — bruyante en dev, fail-closed en production
405
405
 
406
- `TotpService.#resolveKey()` (`totp.ts:204`) tranche au boot :
406
+ `TotpService.#resolveKey()` (`totp.ts:211`) tranche au boot :
407
407
 
408
408
  | Situation | Environnement | Comportement |
409
409
  | ---------------------------- | ------------- | ------------------------------------------------------------ |
410
- | `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts:207`). |
411
- | Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts:221`). |
412
- | Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts:212`). |
410
+ | `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts:214`). |
411
+ | Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts:228`). |
412
+ | Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts:220`). |
413
413
  | `store: "memory"` en prod | production | `WARNING` — secrets volatils, comptes verrouillés au reboot. |
414
414
 
415
415
  Le refus en production est délibéré : une clé éphémère chiffrerait des secrets **illisibles au
@@ -452,20 +452,20 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
452
452
 
453
453
  ## ⚙️ Configuration et mises en situation
454
454
 
455
- La section `totp` du schéma Zod (`config.ts:1138`) — validée au boot, donc une valeur hors bornes
455
+ La section `totp` du schéma Zod (`config.ts:1144`) — validée au boot, donc une valeur hors bornes
456
456
  échoue **au démarrage**, pas au premier login :
457
457
 
458
- | Option | Type | Défaut | Effet |
459
- | --------------- | ---------------------------- | ------ | ----------------------------------------------------------------- |
460
- | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes non montées (`totp.ts:98`). |
461
- | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
462
- | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:537`). |
463
- | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
464
- | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
465
- | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:538`). |
466
- | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:546`). |
467
- | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:574`). |
468
- | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:580`). |
458
+ | Option | Type | Défaut | Effet |
459
+ | --------------- | ---------------------------- | ------ | ------------------------------------------------------------------ |
460
+ | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes non montées (`totp.ts:101`). |
461
+ | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
462
+ | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`). |
463
+ | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
464
+ | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
465
+ | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`). |
466
+ | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`). |
467
+ | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`). |
468
+ | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:594`). |
469
469
 
470
470
  ### Situation 1 — un utilisateur active la 2FA sur son compte
471
471
 
@@ -492,7 +492,7 @@ Le second facteur validé au login ne dit rien de **qui est devant l'écran dix
492
492
  redemande le code : c'est le _sudo mode_.
493
493
 
494
494
  Nodefony fournit le step-up **de login** ; la re-vérification en cours de session, elle, se compose
495
- dans ton controller à partir du service public `TotpService.verifyLogin()` (`totp.ts:262`) :
495
+ dans ton controller à partir du service public `TotpService.verifyLogin()` (`totp.ts:269`) :
496
496
 
497
497
  ```typescript
498
498
  import { controller, Controller, Post } from "@nodefony/framework";
@@ -557,28 +557,30 @@ dialecte via le colKit :
557
557
  | `period` | Durée d'un code (s) | `integer` | `integer` | `int` |
558
558
  | `recoveryCodes` | Condensats des codes **non consommés** | `text` (mode json) | `jsonb` | `json` |
559
559
  | `confirmedAt` | Activation (epoch ms) ou `null` = en attente | `integer` | `bigint` | `bigint` |
560
- | `lastUsedStep` | Dernière tranche `T` validée (**pas** une date) | `integer` | `integer` | `int` |
560
+ | `lastUsedStep` | Dernière tranche `T` validée (**pas** une date) | `integer` | `bigint` | `bigint` |
561
561
  | `createdAt` | Création (epoch ms) | `integer` | `bigint` | `bigint` |
562
562
  | `lastUsedAt` | Dernier usage réussi (epoch ms) ou `null` | `integer` | `bigint` | `bigint` |
563
563
 
564
564
  Deux pièges de lecture, signalés dans l'entité elle-même :
565
565
 
566
566
  - `lastUsedStep` est un **numéro de tranche RFC 6238**, pas un horodatage — d'où le type `int`
567
- partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts:53`).
567
+ partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts:61`).
568
568
  - Un epoch en millisecondes **déborde** un `integer` 32 bits → `bigint` en PostgreSQL et MySQL
569
569
  (SQLite, lui, a des INTEGER 64 bits).
570
570
 
571
- ### Les backends disponibles — et ceux qui manquent
571
+ ### Les backends disponibles
572
572
 
573
573
  | Backend | Enregistré par | Durabilité | État |
574
574
  | ---------- | --------------------------------------------- | ----------------------------------- | -------------------- |
575
575
  | `memory` | builtin (`totpSecretStoreRegistry.ts:54`) | **volatile** — perdu au redémarrage | ✅ dev / tests |
576
- | `drizzle` | `@nodefony/drizzle` (`registerStores.ts:279`) | durable, partagé entre pods | ✅ 3 dialectes SQL |
577
- | `mongoose` | — | — | ⏳ manquant, à venir |
576
+ | `drizzle` | `@nodefony/drizzle` (`registerStores.ts:320`) | durable, partagé entre pods | ✅ 3 dialectes SQL |
577
+ | `mongoose` | `@nodefony/mongoose` (`registerStores.ts`) | durable, partagé entre pods | ✅ MongoDB |
578
578
  | `redis` | — | — | ⏳ manquant, à venir |
579
579
 
580
- Ces deux absences sont des **manques**, pas des choix de périmètre —
581
- mais elles se comblent à deux régimes différents.
580
+ Une application peut donc activer le 2FA **entièrement sur MongoDB**, sans charger d'ORM SQL : une
581
+ application choisit son ORM, elle ne choisit pas de se passer du second facteur.
582
+
583
+ Reste une absence, et c'est un **manque**, pas un choix de périmètre.
582
584
 
583
585
  `redis` le portera **en opt-in explicite, jamais choisi par `auto`** — exactement le régime des
584
586
  passkeys qu'il porte déjà. Un secret TOTP est de la même famille qu'un credential passkey : une
@@ -587,20 +589,19 @@ dehors. Porter l'un et refuser l'autre au nom du « cache évincible » serait i
587
589
  est identique, et il est déjà assumé, avec son avertissement — sur Redis, la persistance devient la
588
590
  responsabilité de l'exploitant (AOF, pas d'éviction sur ces clés).
589
591
 
590
- `mongoose` le portera **au régime normal** : une application choisit son ORM, elle ne choisit pas de
591
- se passer du 2FA — l'objectif est de pouvoir tourner entièrement sur Mongo, sans drizzle. Aujourd'hui, une application MongoDB qui active le 2FA **retombe sur `memory`** (avec la
592
- raison annoncée dans les journaux de boot, et un avertissement en production) : ses secrets ne
593
- survivent pas au redémarrage, et ses utilisateurs se retrouvent verrouillés hors de leur second
594
- facteur. **En attendant** : charger `@nodefony/drizzle` à côté de Mongo — même en SQLite local — suffit
595
- à rendre le store durable, les deux modules cohabitent sans conflit.
592
+ Côté `mongoose`, le store est **100 % portable** lui aussi : tout passe par le contrat
593
+ `IRepository` et le helper `paginate()` d'orm-core, sans query native
594
+ (`MongooseTotpSecretStore.ts`). Le modèle « un secret par utilisateur » y est porté par la clé
595
+ primaire (`_id` = `userId`) — donc l'unicité existe dès le premier document, sans index construit
596
+ en tâche de fond.
596
597
 
597
598
  Côté `drizzle`, les **trois dialectes** sont portés — `TOTP_PORTED` vaut l'ensemble des dialectes
598
- (`registerStores.ts:92`) : SQLite, PostgreSQL, MySQL/MariaDB. Le store n'écrit **aucun SQL natif**,
599
- tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts:38`)
599
+ (`registerStores.ts:94`) : SQLite, PostgreSQL, MySQL/MariaDB. Le store n'écrit **aucun SQL natif**,
600
+ tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts:44`)
600
601
  — c'est ce qui rend la portabilité gratuite.
601
602
 
602
603
  **Comment le backend est choisi.** `store: "auto"` (le défaut) suit l'infra déclarée puis les
603
- adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:134`) :
604
+ adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:137`) :
604
605
 
605
606
  1. `NF_STORE` (override global de banc de charge), s'il est enregistré ici ;
606
607
  2. infra base de données déclarée (`NF_DATABASE_URL`) → `drizzle` ;
@@ -608,7 +609,7 @@ adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:134`) :
608
609
  4. sinon **repli `memory`, annoncé** — jamais silencieux.
609
610
 
610
611
  Un `store` **explicite** introuvable, en revanche, ne se replie pas : `CRITIC` en dev, boot avorté en
611
- production (`totp.ts:167`). Une faute de frappe ne dégrade jamais la sécurité en douce.
612
+ production (`totp.ts:174`). Une faute de frappe ne dégrade jamais la sécurité en douce.
612
613
 
613
614
  ## Le listing paginé des enrôlements
614
615
 
@@ -621,14 +622,14 @@ d'un seul utilisateur.
621
622
  - **pagination native au store** — jamais de parcours complet en mémoire ; l'ordre est contractuel
622
623
  (`createdAt` DESC, départagé par `userId` ASC) ;
623
624
  - **filtres appliqués côté backend** — `confirmed` (activés / en attente) et `q` (préfixe d'`userId`,
624
- donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts:153`) ;
625
+ donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts:176`) ;
625
626
  - **la vue ne peut pas porter de secret** — `ITotpEnrollmentSummary` (`ITotpSecretStore.ts:16`)
626
627
  n'a ni `secretEnc` ni les condensats de récupération, seulement leur **nombre**
627
628
  (`recoveryCodesLeft`).
628
629
 
629
630
  Ce dernier point est une garantie **de contrat**, pas une redaction faite à l'affichage : quel que
630
631
  soit le backend, ces champs ne peuvent pas remonter par ce chemin, même si un appelant les demandait
631
- (`toTotpEnrollment()`, `MemoryTotpSecretStore.ts:18`). C'est ce qu'exerce le banc de contrat partagé.
632
+ (`toTotpEnrollment()`, `MemoryTotpSecretStore.ts:19`). C'est ce qu'exerce le banc de contrat partagé.
632
633
 
633
634
  `countEnrollments()` (`ITotpSecretStore.ts:90`) donne le KPI de couverture sans énumérer une seule
634
635
  ligne.
@@ -637,23 +638,23 @@ ligne.
637
638
 
638
639
  Tout est exporté depuis `@nodefony/security` — signatures complètes dans `.ai/symbols.json`.
639
640
 
640
- **Le service** (`TotpService`, `totp.ts:71`), résolu par nom dans le container (`"totp"`) :
641
+ **Le service** (`TotpService`, `totp.ts:74`), résolu par nom dans le container (`"totp"`) :
641
642
 
642
- | Méthode | Rôle |
643
- | ----------------------------------- | ------------------------------------------------------------ |
644
- | `isEnabled()` (`totp.ts:247`) | 2FA opérationnel (activé en config **et** boot réussi). |
645
- | `beginEnrollment()` (`totp.ts:252`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
646
- | `confirmEnrollment()` (`:257`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
647
- | `verifyLogin()` (`totp.ts:262`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
648
- | `disable()` (`totp.ts:267`) | Retire secret et codes. |
649
- | `status()` (`totp.ts:272`) | `{ enabled, pending, recoveryCodesRemaining }`. |
650
- | `isEnabledFor()` (`totp.ts:299`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
651
- | `listPage()` (`totp.ts:283`) | Page d'enrôlements (data plane admin). |
652
- | `countEnrollments()` (`:294`) | Compte filtré, sans énumération. |
643
+ | Méthode | Rôle |
644
+ | ------------------------------------- | ------------------------------------------------------------ |
645
+ | `isEnabled()` (`totp.ts:254`) | 2FA opérationnel (activé en config **et** boot réussi). |
646
+ | `beginEnrollment()` (`totp.ts:259`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
647
+ | `confirmEnrollment()` (`totp.ts:264`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
648
+ | `verifyLogin()` (`totp.ts:269`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
649
+ | `disable()` (`totp.ts:274`) | Retire secret et codes. |
650
+ | `status()` (`totp.ts:279`) | `{ enabled, pending, recoveryCodesRemaining }`. |
651
+ | `isEnabledFor()` (`totp.ts:306`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
652
+ | `listPage()` (`totp.ts:290`) | Page d'enrôlements (data plane admin). |
653
+ | `countEnrollments()` (`totp.ts:301`) | Compte filtré, sans énumération. |
653
654
 
654
655
  **Les opérations pures**, si tu veux le 2FA **sans** le service (test, script, autre transport) —
655
656
  elles prennent leurs dépendances en argument : `beginTotpEnrollment()`, `confirmTotpEnrollment()`,
656
- `verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts:72`).
657
+ `verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts:200`).
657
658
 
658
659
  **Les primitives crypto**, pour écrire un client ou un banc de test : `totpCode()`
659
660
  (`totpCrypto.ts:174`), `base32Decode()` (`totpCrypto.ts:81`), `deriveTotpKey()`
@@ -698,7 +699,7 @@ vérifie que ta projection n'expose ni secret ni condensat.
698
699
  | Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts:54`) |
699
700
  | Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts:30`) |
700
701
  | Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts:330`) |
701
- | Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:264`) |
702
+ | Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) |
702
703
  | Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts:145`) |
703
704
 
704
705
  Les **vecteurs de test de la RFC 6238 (Appendix B)** sont rejoués en test sur les trois fonctions de
@@ -708,18 +709,18 @@ hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
708
709
 
709
710
  Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas.
710
711
 
711
- - **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts:455`)
712
+ - **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts:458`)
712
713
  résout le service **une seule fois** puis met le résultat en cache. Service absent ⇒ `null` ⇒
713
714
  **zéro accès au store**, zéro allocation par login.
714
715
  - **Aucun coût par requête** : le TOTP n'est pas un authenticator du firewall, il ne s'exécute donc
715
716
  jamais dans le pipeline HTTP/WS.
716
717
  - **Allocation paresseuse du store** : la `Map` de `MemoryTotpSecretStore`
717
- (`MemoryTotpSecretStore.ts:62`) n'existe que si le 2FA est activé — le service ne construit rien
718
- quand `totp.enabled` est `false` (`totp.ts:98`).
718
+ (`MemoryTotpSecretStore.ts:74`) n'existe que si le 2FA est activé — le service ne construit rien
719
+ quand `totp.enabled` est `false` (`totp.ts:101`).
719
720
  - **Le coût réel d'une vérification** : ≤ `2·window + 1` HMAC (3 par défaut) + un déchiffrement
720
721
  AES-GCM. De l'ordre de la microseconde — négligeable devant le hachage Argon2id du mot de passe
721
722
  qui l'a précédé.
722
- - **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts:234`)
723
+ - **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts:241`)
723
724
  le déclenche à `onTerminate` — aucune écriture en attente perdue.
724
725
 
725
726
  ## 📡 Observabilité — Studio
@@ -733,34 +734,34 @@ Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas
733
734
 
734
735
  Le data plane admin correspondant, gardé par `ROLE_NODEFONY_ADMIN` :
735
736
 
736
- - `GET /nodefony/security/api/totp/list` — couverture 2FA paginée (`SecurityAdminApi.ts:606`).
737
+ - `GET /nodefony/security/api/totp/list` — couverture 2FA paginée (`SecurityAdminApi.ts:636`).
737
738
  Réponse **honnête** si le 2FA est désactivé : `{ enabled: false, items: [] }`, jamais une erreur —
738
739
  la console doit pouvoir afficher « 2FA désactivé ».
739
- - `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts:658`).
740
+ - `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts:686`).
740
741
  - `POST /nodefony/security/api/users/{id}/totp/disable` — reset admin, **audité**
741
- (`SecurityAdminApi.ts:683`).
742
+ (`SecurityAdminApi.ts:711`).
742
743
 
743
744
  L'admin peut **désactiver**, jamais **activer** pour autrui : le secret se scanne sur l'appareil de
744
745
  l'utilisateur, lui seul peut l'armer.
745
746
 
746
747
  Côté journal d'audit, quatre actions tracent le cycle : `login.mfa_required` (`authFlow.ts:177`),
747
- `login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts:299`), `login.failure` avec
748
+ `login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts:232`), `login.failure` avec
748
749
  `reason: "mfa_invalid"` (`authFlow.ts:291`), et `user.totp_disabled` côté admin
749
- (`SecurityAdminApi.ts:707`).
750
+ (`SecurityAdminApi.ts:735`).
750
751
 
751
752
  ## ⚠️ Pièges (symptôme → cause → correction)
752
753
 
753
754
  | Symptôme | Cause (dans le code) | Correction |
754
755
  | ------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------ |
755
- | 2FA inactif en production, `CRITIC` au boot | `totp.encryptionKey` absente — fail-closed (`totp.ts:212`) | `npx nodefony security:secrets`, puis câbler `ctx.env.NF_TOTP_KEY` |
756
+ | 2FA inactif en production, `CRITIC` au boot | `totp.encryptionKey` absente — fail-closed (`totp.ts:220`) | `npx nodefony security:secrets`, puis câbler `ctx.env.NF_TOTP_KEY` |
756
757
  | Tous les secrets illisibles après déploiement | Clé éphémère (dev) ou `TOTP_DERIVATION` modifié | Clé **stable** partagée ; ne jamais toucher au contexte HKDF |
757
758
  | Secrets perdus à chaque redémarrage | Store résolu en `memory` (aucun adapter durable chargé) | Charger `@nodefony/drizzle` ou déclarer `NF_DATABASE_URL` |
758
759
  | Code « juste » systématiquement refusé | Horloge décalée de plus d'un pas (fenêtre = ±30 s) | Synchroniser NTP serveur **et** téléphone |
759
760
  | Le QR est scanné mais aucun code ne passe | `digits`/`algorithm` non standard, ignorés par l'app | Rester en `SHA1` / 6 chiffres |
760
761
  | `202` au login au lieu de `200` | Comportement **attendu** : second facteur requis | Enchaîner sur `POST …/auth/login/totp` |
761
762
  | `401` sur `…/auth/me` juste après le mot de passe | L'identité n'est posée qu'après le 2ᵉ facteur (`authFlow.ts:170`) | Terminer le step-up |
762
- | `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts:264`) | Respecter `Retry-After` — attendu sous attaque |
763
- | `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts:128`) | Vérifier `totp.enabled` + la clé + les logs de boot |
763
+ | `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) | Respecter `Retry-After` — attendu sous attaque |
764
+ | `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts:21`) | Vérifier `totp.enabled` + la clé + les logs de boot |
764
765
  | Utilisateur bloqué, plus aucun code | Codes de récupération épuisés | Reset admin via `…/users/{id}/totp/disable`, puis ré-enrôlement |
765
766
  | Même code accepté deux fois | Impossible — anti-rejeu `lastUsedStep` (`totpOperations.ts:173`) | — |
766
767
  | Code de récupération réutilisable | Impossible — retiré du stock à l'usage (`totpOperations.ts:186`) | Régénérer un lot en ré-enrôlant si le stock est bas |