@nodefony/security 10.0.0-beta.3 → 10.0.0-beta.4

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 (162) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +6 -8
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
  4. package/dist/nodefony/command/security-oauth-doctor.js +5 -3
  5. package/dist/nodefony/command/security-secrets.js +10 -4
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +5 -3
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +5 -3
  10. package/dist/nodefony/config/config.js +20 -4
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  14. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  15. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  16. package/dist/nodefony/errors/CsrfError.js +1 -2
  17. package/dist/nodefony/errors/InsecureTransportError.js +1 -2
  18. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  19. package/dist/nodefony/errors/SsrfError.js +1 -2
  20. package/dist/nodefony/errors/ThrottledError.js +1 -2
  21. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  22. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  23. package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
  24. package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
  25. package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
  26. package/dist/nodefony/keycloak/scaffold.js +3 -3
  27. package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
  28. package/dist/nodefony/scaffold/contribute.js +1 -2
  29. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  30. package/dist/nodefony/service/apiKeys.js +45 -5
  31. package/dist/nodefony/service/auditService.js +4 -5
  32. package/dist/nodefony/service/authFlow.js +10 -8
  33. package/dist/nodefony/service/authorization.js +1 -2
  34. package/dist/nodefony/service/cors.js +1 -2
  35. package/dist/nodefony/service/csrf.js +1 -2
  36. package/dist/nodefony/service/firewall.js +5 -4
  37. package/dist/nodefony/service/keycloak.js +1 -2
  38. package/dist/nodefony/service/oauth2.js +29 -7
  39. package/dist/nodefony/service/securityHeaders.js +1 -2
  40. package/dist/nodefony/service/tokenService.js +38 -22
  41. package/dist/nodefony/service/totp.js +4 -5
  42. package/dist/nodefony/service/webAuthn.js +4 -5
  43. package/dist/nodefony/service/webhooks.js +4 -5
  44. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  45. package/dist/nodefony/src/SecuredArea.js +1 -2
  46. package/dist/nodefony/src/accountBinding.js +96 -0
  47. package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
  48. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  49. package/dist/nodefony/src/admin/adminAudit.js +1 -2
  50. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  51. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  52. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  53. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  54. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  55. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  56. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  57. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  58. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  59. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  60. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
  61. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  62. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
  63. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
  64. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  65. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
  66. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
  67. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  68. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  69. package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
  70. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  71. package/dist/nodefony/src/csp.js +1 -2
  72. package/dist/nodefony/src/csrfToken.js +1 -2
  73. package/dist/nodefony/src/entryPoint.js +1 -2
  74. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  75. package/dist/nodefony/src/net/transportFailure.js +57 -0
  76. package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
  77. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  78. package/dist/nodefony/src/oauth/metadata.js +5 -16
  79. package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
  80. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
  81. package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
  82. package/dist/nodefony/src/oauth/providerRoles.js +1 -2
  83. package/dist/nodefony/src/oauth/providers/github.js +16 -6
  84. package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
  85. package/dist/nodefony/src/openToSignup.js +1 -2
  86. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  87. package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
  88. package/dist/nodefony/src/rolesWithin.js +57 -0
  89. package/dist/nodefony/src/sessionAuthentication.js +1 -2
  90. package/dist/nodefony/src/sessionIdentity.js +29 -3
  91. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  92. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  93. package/dist/nodefony/src/token/JwtKeystore.js +14 -5
  94. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  95. package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
  96. package/dist/nodefony/src/token/UserToken.js +1 -2
  97. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  98. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  99. package/dist/nodefony/src/token/secretFile.js +1 -2
  100. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  101. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  102. package/dist/nodefony/src/token/tokenSort.js +1 -2
  103. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  106. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  107. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  108. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  110. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
  111. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  112. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  113. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  114. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  115. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  116. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  117. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  118. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  119. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  120. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  124. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
  125. package/dist/types/nodefony/config/config.d.ts +1 -0
  126. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  127. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  128. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  129. package/dist/types/nodefony/service/oauth2.d.ts +20 -0
  130. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  131. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
  132. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
  133. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  134. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
  135. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  136. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  137. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  138. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  139. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  140. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  141. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  142. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  143. package/docs/api-keys.md +181 -174
  144. package/docs/audit.md +140 -140
  145. package/docs/authenticators.md +101 -101
  146. package/docs/authorization.md +116 -69
  147. package/docs/cors.md +64 -64
  148. package/docs/csrf.md +74 -71
  149. package/docs/external-jwt.md +22 -12
  150. package/docs/firewall.md +85 -82
  151. package/docs/headers.md +81 -81
  152. package/docs/index.md +4 -4
  153. package/docs/keycloak.md +95 -78
  154. package/docs/login-page.md +23 -23
  155. package/docs/oauth2.md +157 -146
  156. package/docs/obtenir-un-jeton.md +116 -26
  157. package/docs/tokens.md +128 -122
  158. package/docs/totp.md +136 -136
  159. package/docs/webauthn.md +153 -153
  160. package/docs/webhooks.md +127 -124
  161. package/package.json +13 -13
  162. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/totp.md CHANGED
@@ -66,7 +66,7 @@ flowchart TD
66
66
 
67
67
  Tant que le second facteur n'est pas validé, **l'identité n'est pas établie** : `session.user` reste
68
68
  vide et le Zero Trust du firewall répond 401 sur tout le reste (`AuthFlow.login()`,
69
- `authFlow.ts:142`).
69
+ `authFlow.ts#AuthFlow.login`).
70
70
 
71
71
  ## 📖 Lexique
72
72
 
@@ -110,29 +110,29 @@ 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:74`)
113
+ **1. La logique est PURE, le service n'est qu'une prise de courant.** `TotpService` (`totp.ts#TotpService`)
114
114
  résout au boot deux choses seulement : le **store** de secrets et la **clé de chiffrement**
115
- (`TotpService.#build()`, `totp.ts:92`). Tout le reste — enrôler, confirmer, vérifier — vit dans des
115
+ (`TotpService.#build()`, `totp.ts#TotpService.#build`). 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
- (`ITotpDeps`, `totpOperations.ts:22`). Conséquence directe : la logique critique se teste **sans
117
+ (`ITotpDeps`, `totpOperations.ts#ITotpDeps`). 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 %.
119
119
 
120
120
  **2. Le secret est CHIFFRÉ, jamais haché.** Un mot de passe se hache (à sens unique) parce que le
121
121
  serveur n'a qu'à le **comparer**. Un secret TOTP, lui, doit être **relu en clair** à chaque
122
122
  vérification pour recalculer le code → il est **chiffré** en AES-256-GCM
123
- (`ITotpSecret.secretEnc`, `ITotpSecret.ts:18`). C'est la différence de nature qui commande la
123
+ (`ITotpSecret.secretEnc`, `ITotpSecret.ts#ITotpSecret.secretEnc`). C'est la différence de nature qui commande la
124
124
  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:297`). Même dessin que
128
+ passe et l'ouverture de session (`AuthFlow.completeMfaLogin()`, `authFlow.ts#AuthFlow.completeMfaLogin`). 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
- > qu'une interface locale de trois méthodes (`ITotpLoginVerifier`, `authFlow.ts:79`). 2FA désactivé
133
+ > qu'une interface locale de trois méthodes (`ITotpLoginVerifier`, `authFlow.ts#ITotpLoginVerifier`). 2FA désactivé
134
134
  > ⇒ service absent ⇒ le login nominal **ne paie strictement rien** (`AuthFlow.#resolveTotp()`,
135
- > `authFlow.ts:642`).
135
+ > `authFlow.ts#AuthFlow.#resolveTotp`).
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:79`) :
146
+ (`nodefony security:secrets`, `security-secrets.ts#SecuritySecrets`) :
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:156`) et
157
+ Elle produit 32 octets aléatoires en base64 (`randomBytes(32)`, `security-secrets.ts#SecuritySecrets.generate`) 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é
@@ -190,7 +190,7 @@ export default defineConfig<typeof env>((ctx) => ({
190
190
 
191
191
  ### 3. Les endpoints sont FOURNIS — tu n'écris aucun controller
192
192
 
193
- `mountTotpRoutes()` (`TotpController.ts:161`) monte quatre routes self-service dès que
193
+ `mountTotpRoutes()` (`TotpController.ts#mountTotpRoutes`) monte quatre routes self-service dès que
194
194
  `@nodefony/security` est chargé (le service `totp` est alors toujours enregistré). 2FA désactivé, ou
195
195
  service non initialisé : elles répondent `503 2FA unavailable` :
196
196
 
@@ -202,7 +202,7 @@ service non initialisé : elles répondent `503 2FA unavailable` :
202
202
  | `GET /nodefony/security/api/totp/status` | — | `{ enabled, pending, recoveryCodesRemaining }` |
203
203
 
204
204
  Et côté login, deux routes du flux de session BFF (`mountSessionAuthRoutes()`,
205
- `SessionAuthController.ts:214`) :
205
+ `SessionAuthController.ts#mountSessionAuthRoutes`) :
206
206
 
207
207
  | Route | Corps | Réponse |
208
208
  | --------------------------------------------- | ------------------------ | ------------------------------------------ |
@@ -216,9 +216,9 @@ les méthodes qu'il sait conduire ; `NodefonyLogin` traite un défi sans aucune
216
216
  une réponse inattendue (erreur `server`), jamais comme un écran de code.
217
217
 
218
218
  > [!WARNING]
219
- > Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts:52`) : elles vivent dans la
219
+ > Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts#TotpController`) : elles vivent dans la
220
220
  > zone data plane et exigent une session BFF. Le sujet est **toujours** l'utilisateur courant, lu
221
- > depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:145`) — jamais un
221
+ > depuis la session (`TotpController.#currentSubject()`, `TotpController.ts#TotpController.#currentSubject`) — jamais un
222
222
  > paramètre : on n'active ni ne désactive le 2FA d'autrui (anti-IDOR).
223
223
 
224
224
  ### 4. Ce qu'on observe
@@ -297,18 +297,18 @@ sequenceDiagram
297
297
 
298
298
  Ce que le code garantit à chaque étape :
299
299
 
300
- - **`beginTotpEnrollment()`** (`totpOperations.ts:72`) écrit le secret **déjà chiffré** avec
300
+ - **`beginTotpEnrollment()`** (`totpOperations.ts#beginTotpEnrollment`) écrit le secret **déjà chiffré** avec
301
301
  `confirmedAt: null` — l'état « en attente ». Il est **idempotent** : rappeler l'enrôlement écrase
302
302
  simplement le précédent non confirmé (l'utilisateur qui a raté son scan recommence, sans support).
303
- - **`confirmTotpEnrollment()`** (`totpOperations.ts:112`) refuse si aucun enrôlement n'est en cours
303
+ - **`confirmTotpEnrollment()`** (`totpOperations.ts#confirmTotpEnrollment`) refuse si aucun enrôlement n'est en cours
304
304
  ou s'il est déjà confirmé, et **reste en attente** si le code est faux — aucun état intermédiaire
305
305
  bancal.
306
306
  - Le pas qui a servi à confirmer est marqué **consommé** (`lastUsedStep: res.step`,
307
- `totpOperations.ts:142`) : le code de confirmation n'est pas rejouable comme premier code de login.
307
+ `totpOperations.ts#confirmTotpEnrollment`) : le code de confirmation n'est pas rejouable comme premier code de login.
308
308
 
309
309
  > [!TIP]
310
310
  > Le HTTP ne laisse jamais fuir le détail : code faux, enrôlement absent, ou déjà confirmé donnent
311
- > tous le **même** `400 Invalid or expired code` (`TotpController.confirm()`, `TotpController.ts:82`).
311
+ > tous le **même** `400 Invalid or expired code` (`TotpController.confirm()`, `TotpController.ts#TotpController.confirm`).
312
312
  > La cause fine reste côté serveur.
313
313
 
314
314
  ### La vérification au login
@@ -340,22 +340,22 @@ sequenceDiagram
340
340
  A-->>U: 200 { user }
341
341
  ```
342
342
 
343
- Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:297`) :
343
+ Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts#AuthFlow.completeMfaLogin`) :
344
344
 
345
345
  1. **Le défi vit en session, pas dans l'URL ni dans un jeton client** — clé `mfa:pending`
346
- (`authFlow.ts:283`), posée par le login, **consommée** avant l'ouverture de session
347
- (`authFlow.ts:301`).
346
+ (`authFlow.ts#AuthFlow.completeMfaLogin`), posée par le login, **consommée** avant l'ouverture de session
347
+ (`authFlow.ts#AuthFlow.completeMfaLogin`).
348
348
  2. **Le code à 6 chiffres est throttlé** comme un mot de passe — même backoff partagé
349
- (`AuthFlow.#resolveThrottler()`, `authFlow.ts:630`) : 10⁶ combinaisons se forcent brute en
349
+ (`AuthFlow.#resolveThrottler()`, `authFlow.ts#AuthFlow.#resolveThrottler`) : 10⁶ combinaisons se forcent brute en
350
350
  quelques minutes sans lui. Trop de tentatives → `429` + `Retry-After`.
351
351
  3. **Un échec ne détruit pas le défi** — l'utilisateur qui s'est trompé de chiffre ressaisit ; il
352
352
  n'a pas à refaire son mot de passe.
353
353
 
354
354
  ### La fenêtre de dérive et l'anti-rejeu
355
355
 
356
- Les deux horloges ne sont jamais parfaitement synchrones. `verifyTotp()` (`totpCrypto.ts:207`)
356
+ Les deux horloges ne sont jamais parfaitement synchrones. `verifyTotp()` (`totpCrypto.ts#verifyTotp`)
357
357
  balaie donc les tranches `T-window … T+window` et compare **en temps constant**
358
- (`timingSafeEqual`, `totpCrypto.ts:227`) — une comparaison naïve fuirait le préfixe correct par le
358
+ (`timingSafeEqual`, `totpCrypto.ts#verifyTotp`) — une comparaison naïve fuirait le préfixe correct par le
359
359
  temps de réponse.
360
360
 
361
361
  | `window` | Tolérance réelle | Codes acceptés simultanément | Verdict |
@@ -365,8 +365,8 @@ temps de réponse.
365
365
  | `2` | ±60 s | 5 | Surface d'attaque ×1,7 pour peu de gain. |
366
366
 
367
367
  Le contrepoids obligatoire, c'est l'**anti-rejeu** : la tranche qui a validé est mémorisée
368
- (`ITotpSecret.lastUsedStep`, `ITotpSecret.ts:36`), et tout code d'une tranche **≤** à la dernière
369
- consommée est refusé par la garde `lastUsedStep` (`totpOperations.ts:173`). Un code intercepté —
368
+ (`ITotpSecret.lastUsedStep`, `ITotpSecret.ts#ITotpSecret.lastUsedStep`), et tout code d'une tranche **≤** à la dernière
369
+ consommée est refusé par la garde `lastUsedStep` (`totpOperations.ts#verifyTotpLogin`). Un code intercepté —
370
370
  épaule, proxy, phishing en temps réel — est donc **mort dès qu'il a servi une fois**.
371
371
 
372
372
  > [!WARNING]
@@ -379,52 +379,52 @@ consommée est refusé par la garde `lastUsedStep` (`totpOperations.ts:173`). Un
379
379
 
380
380
  La valeur de `totp.encryptionKey` est une chaîne d'application : passphrase, hex, base64, longueur
381
381
  quelconque. AES-256 exige exactement **32 octets de haute entropie**. `deriveKey()`
382
- (`secretCipher.ts:54`) passe donc le matériel dans **HKDF-SHA256** (RFC 5869) :
382
+ (`secretCipher.ts#deriveKey`) passe donc le matériel dans **HKDF-SHA256** (RFC 5869) :
383
383
 
384
384
  - **Déterministe** — tous les pods d'un cluster dérivent la **même** clé du même secret : un secret
385
385
  écrit par un pod se relit par les autres, sans réplication de clé.
386
386
  - **Séparation de domaine** — chaque brique dérive avec un `salt`/`info` distinct. Le contexte TOTP
387
- est figé (`TOTP_DERIVATION`, `totpCipher.ts:23`) : un blob de webhook ne se déchiffre **jamais**
387
+ est figé (`TOTP_DERIVATION`, `totpCipher.ts#TOTP_DERIVATION`) : un blob de webhook ne se déchiffre **jamais**
388
388
  avec la clé TOTP, par construction, même si la clé maître de config est la même.
389
389
  - **Longueur libre en entrée** — une passphrase courte ne devient jamais une clé AES faible.
390
390
 
391
391
  ### Le format du blob
392
392
 
393
- `encryptSecret()` (`secretCipher.ts:77`) produit une chaîne opaque, préfixée par sa version :
393
+ `encryptSecret()` (`secretCipher.ts#encryptSecret`) produit une chaîne opaque, préfixée par sa version :
394
394
 
395
395
  ```
396
396
  gcm1.<base64url( iv‖tag‖ciphertext )>
397
397
  └ 12 o ┘└16 o┘
398
398
  ```
399
399
 
400
- - **IV de 12 octets tiré à chaque chiffrement** (`secretCipher.ts:30`) — deux enrôlements du même
400
+ - **IV de 12 octets tiré à chaque chiffrement** (`secretCipher.ts#IV_BYTES`) — deux enrôlements du même
401
401
  secret donnent deux blobs différents.
402
402
  - **GCM = chiffrement authentifié** : le tag de 16 octets fait échouer `decryptSecret()`
403
- (`secretCipher.ts:90`) si le blob a été altéré **ou** si la clé est la mauvaise. GCM ne distingue
403
+ (`secretCipher.ts#decryptSecret`) si le blob a été altéré **ou** si la clé est la mauvaise. GCM ne distingue
404
404
  pas les deux cas, par construction — toute manipulation du secret stocké est donc détectée.
405
405
  - **Préfixe versionné `gcm1`** : une rotation d'algorithme future pourra cohabiter avec les secrets
406
406
  existants.
407
407
 
408
408
  Le store, lui, ne voit que des octets : il ne déchiffre jamais rien (`ITotpSecret.secretEnc`,
409
- `ITotpSecret.ts:18`).
409
+ `ITotpSecret.ts#ITotpSecret.secretEnc`).
410
410
 
411
411
  ### La politique de clé — bruyante en dev, fail-closed en production
412
412
 
413
- `TotpService.#resolveKey()` (`totp.ts:211`) tranche au boot :
413
+ `TotpService.#resolveKey()` (`totp.ts#TotpService.#resolveKey`) tranche au boot :
414
414
 
415
- | Situation | Environnement | Comportement |
416
- | ---------------------------- | ------------- | ------------------------------------------------------------ |
417
- | `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts:214`). |
418
- | Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts:228`). |
419
- | Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts:220`). |
420
- | `store: "memory"` en prod | production | `WARNING` — secrets volatils, comptes verrouillés au reboot. |
415
+ | Situation | Environnement | Comportement |
416
+ | ---------------------------- | ------------- | ------------------------------------------------------------------- |
417
+ | `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts#TotpService.#resolveKey`). |
418
+ | Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts#TotpService.#resolveKey`). |
419
+ | Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts#TotpService.#resolveKey`). |
420
+ | `store: "memory"` en prod | production | `WARNING` — secrets volatils, comptes verrouillés au reboot. |
421
421
 
422
422
  Le refus en production est délibéré : une clé éphémère chiffrerait des secrets **illisibles au
423
423
  redémarrage suivant** et sur les autres pods — les utilisateurs seraient enfermés dehors, sans
424
424
  message. Mieux vaut un 2FA absent et bruyant qu'un 2FA qui casse silencieusement en pleine nuit.
425
425
 
426
426
  > [!CAUTION]
427
- > Ne **jamais** modifier `TOTP_DERIVATION` (`totpCipher.ts:23`). Changer son sel ou son `info` rend
427
+ > Ne **jamais** modifier `TOTP_DERIVATION` (`totpCipher.ts#TOTP_DERIVATION`). Changer son sel ou son `info` rend
428
428
  > illisibles **tous** les secrets déjà stockés — chaque utilisateur devra ré-enrôler.
429
429
 
430
430
  ## Les codes de récupération — perdre son téléphone
@@ -432,24 +432,24 @@ message. Mieux vaut un 2FA absent et bruyant qu'un 2FA qui casse silencieusement
432
432
  Un second facteur crée un risque neuf : **s'enfermer dehors**. Les codes de récupération sont la
433
433
  sortie de secours — le NIST les classe comme _look-up secrets_ (SP 800-63B §5.1.2).
434
434
 
435
- **Comment ils sont fabriqués** (`generateRecoveryCodes()`, `totpCrypto.ts:330`) :
435
+ **Comment ils sont fabriqués** (`generateRecoveryCodes()`, `totpCrypto.ts#generateRecoveryCodes`) :
436
436
 
437
437
  - 10 codes par défaut (`totp.recoveryCodes`), au format lisible `XXXXX-XXXXX` ;
438
- - alphabet **sans caractères ambigus** — ni `I`, ni `L`, ni `O`, ni `U` (`totpCrypto.ts:269`) : on les
438
+ - alphabet **sans caractères ambigus** — ni `I`, ni `L`, ni `O`, ni `U` (`totpCrypto.ts#unbiasedIndices`) : on les
439
439
  recopie à la main, souvent sous stress ;
440
440
  - ~50 bits d'aléa chacun — non devinable, mais ce n'est **pas** un mot de passe humain.
441
441
 
442
- **Comment ils sont stockés** : en condensat `sha256` (`hashRecoveryCode()`, `totpCrypto.ts:347`),
442
+ **Comment ils sont stockés** : en condensat `sha256` (`hashRecoveryCode()`, `totpCrypto.ts#hashRecoveryCode`),
443
443
  jamais en clair. Un `sha256` simple suffit ici, précisément parce que l'entrée est **aléatoire** (une
444
444
  attaque par dictionnaire n'a rien à mordre) — contrairement à un mot de passe, qui exige Argon2id.
445
445
 
446
446
  **Comment ils sont consommés** : au login, si le code présenté n'est pas un TOTP valide,
447
447
  `verifyTotpLogin()` cherche une correspondance parmi les condensats — **en temps constant sur chaque
448
448
  entrée**, et sans court-circuit à la première trouvaille (`matchRecoveryCode()`,
449
- `totpCrypto.ts:356`). Le code trouvé est **retiré de la liste** (`totpOperations.ts:186`) : usage
449
+ `totpCrypto.ts#matchRecoveryCode`). Le code trouvé est **retiré de la liste** (`totpOperations.ts#verifyTotpLogin`) : usage
450
450
  unique, strictement.
451
451
 
452
- La saisie est tolérante — casse et tirets ignorés à la normalisation (`totpCrypto.ts:272`) :
452
+ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totpCrypto.ts#unbiasedIndices`) :
453
453
  `k7m2p9xq4r` vaut `K7M2P-9XQ4R`.
454
454
 
455
455
  > [!TIP]
@@ -459,20 +459,20 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
459
459
 
460
460
  ## ⚙️ Configuration et mises en situation
461
461
 
462
- La section `totp` du schéma Zod (`config.ts:1461`) — validée au boot, donc une valeur hors bornes
462
+ La section `totp` du schéma Zod (`config.ts#securityConfigSchema`) — validée au boot, donc une valeur hors bornes
463
463
  échoue **au démarrage**, pas au premier login :
464
464
 
465
- | Option | Type | Défaut | Effet |
466
- | --------------- | ---------------------------- | ------ | --------------------------------------------------------------- |
467
- | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes en `503` (`totp.ts:101`). |
468
- | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
469
- | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:618`). |
470
- | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
471
- | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
472
- | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:639`). |
473
- | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:586`). |
474
- | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:655`). |
475
- | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:595`). |
465
+ | Option | Type | Défaut | Effet |
466
+ | --------------- | ---------------------------- | ------ | ------------------------------------------------------------------------------ |
467
+ | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes en `503` (`totp.ts#TotpService.#build`). |
468
+ | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
469
+ | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts#totpSchema`). |
470
+ | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
471
+ | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
472
+ | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts#totpSchema`). |
473
+ | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts#passkeysSchema`). |
474
+ | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts#totpSchema`). |
475
+ | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts#passkeysSchema`). |
476
476
 
477
477
  ### Situation 1 — un utilisateur active la 2FA sur son compte
478
478
 
@@ -481,7 +481,7 @@ self-service sont déjà là, la console Studio les consomme déjà (`/nodefony/
481
481
 
482
482
  ```typescript
483
483
  use("@nodefony/security", {
484
- totp: { encryptionKey: process.env.NF_TOTP_KEY },
484
+ totp: { encryptionKey: ctx.env.NF_TOTP_KEY },
485
485
  });
486
486
  ```
487
487
 
@@ -515,7 +515,7 @@ passe puis code — et rejoue l'action. Évaluée APRÈS `@IsGranted` : un rôle
515
515
  Une session qui ne dit pas comment elle a été ouverte est refusée.
516
516
 
517
517
  **La voie composée** — quand le code doit être saisi DANS l'action, sans repasser par la page de
518
- connexion, à partir du service public `TotpService.verifyLogin()` (`totp.ts:300`) :
518
+ connexion, à partir du service public `TotpService.verifyLogin()` (`totp.ts#TotpService.verifyLogin`) :
519
519
 
520
520
  ```typescript
521
521
  import { controller, Controller, Post } from "@nodefony/framework";
@@ -526,7 +526,7 @@ class DangerController extends Controller {
526
526
  @Post("/delete")
527
527
  async remove() {
528
528
  const totp = this.get<TotpService>("totp");
529
- const code = (this.queryPost as { code?: unknown }).code;
529
+ const code = (this.body as { code?: unknown }).code;
530
530
  // Zone protégée : le firewall a déjà authentifié. On exige la PREUVE FRAÎCHE.
531
531
  if (!totp?.isEnabled() || typeof code !== "string") {
532
532
  return this.renderJson({ error: "2FA required" }, 403);
@@ -557,7 +557,7 @@ totp: { algorithm: "SHA512", digits: 8 }, // ❌ Google Authenticator ignore c
557
557
  ```
558
558
 
559
559
  L'URI `otpauth://` transporte bien `algorithm` et `digits` (`buildOtpauthUri()`,
560
- `totpCrypto.ts:253`), mais plusieurs applications grand public les **ignorent** et calculent en
560
+ `totpCrypto.ts#buildOtpauthUri`), mais plusieurs applications grand public les **ignorent** et calculent en
561
561
  `SHA1`/6 chiffres. Résultat : le QR est scanné, l'app affiche un code… systématiquement refusé, sans
562
562
  que rien ne semble anormal. Le gain de sécurité réel est par ailleurs nul — l'anti-rejeu et le
563
563
  throttle bornent déjà les tentatives bien avant l'espace des codes.
@@ -565,23 +565,23 @@ throttle bornent déjà les tentatives bien avant l'espace des codes.
565
565
  ## L'entité de persistance — un secret par utilisateur
566
566
 
567
567
  Le modèle est volontairement minimal : **clé naturelle = `userId`**, `save` est un upsert
568
- (`ITotpSecretStore`, `ITotpSecretStore.ts:75`). Pas d'identifiant de ligne, pas d'index secondaire —
568
+ (`ITotpSecretStore`, `ITotpSecretStore.ts#ITotpSecretStore`). Pas d'identifiant de ligne, pas d'index secondaire —
569
569
  tout accès passe par la clé primaire.
570
570
 
571
571
  **`userId` vaut `IUser.identifier`, jamais `IUser.id`** — la même clé que la session et les jetons,
572
572
  et elle est immuable. Les deux sont des chaînes : un appelant qui passe l'`id` ne se fait pas
573
573
  refuser, il vise une clé vide et reçoit un succès qui n'a rien touché. Le plan d'administration,
574
574
  qui adresse les comptes par leur `id`, traduit donc d'abord l'`id` en `identifier`
575
- (`resolveSubject`, `SecurityAdminApi.ts:270`).
575
+ (`resolveSubject`, `SecurityAdminApi.ts#resolveSubject`).
576
576
 
577
577
  **Le secret disparaît avec le compte.** Supprimer un compte (plan d'administration ou
578
578
  `security:user:delete`) passe par `UserService.delete`, qui émet `onUserRevoked`
579
579
  (`reason: "deleted"`) ; la cascade retire le secret
580
- TOTP et les passkeys (`cascadeUserRevocation`, `userRevocationCascade.ts:51`). Sans elle, un compte
580
+ TOTP et les passkeys (`cascadeUserRevocation`, `userRevocationCascade.ts#cascadeUserRevocation`). Sans elle, un compte
581
581
  recréé sous le même identifiant hériterait du second facteur de l'ancien titulaire. Un compte
582
582
  seulement désactivé ou verrouillé garde ses facteurs.
583
583
 
584
- Spécification logique de la table (`TOTP_SECRET_TABLE_SPEC`, `totpSecretEntity.ts:36`), déclinée par
584
+ Spécification logique de la table (`TOTP_SECRET_TABLE_SPEC`, `totpSecretEntity.ts#TOTP_SECRET_TABLE_SPEC`), déclinée par
585
585
  dialecte via le colKit :
586
586
 
587
587
  | Colonne | Rôle | SQLite | PostgreSQL | MySQL / MariaDB |
@@ -600,18 +600,18 @@ dialecte via le colKit :
600
600
  Deux pièges de lecture, signalés dans l'entité elle-même :
601
601
 
602
602
  - `lastUsedStep` est un **numéro de tranche RFC 6238**, pas un horodatage — d'où le type `int`
603
- partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts:61`).
603
+ partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts#TotpSecretRow.algorithm`).
604
604
  - Un epoch en millisecondes **déborde** un `integer` 32 bits → `bigint` en PostgreSQL et MySQL
605
605
  (SQLite, lui, a des INTEGER 64 bits).
606
606
 
607
607
  ### Les backends disponibles
608
608
 
609
- | Backend | Enregistré par | Durabilité | État |
610
- | ---------- | --------------------------------------------- | ----------------------------------- | -------------------- |
611
- | `memory` | builtin (`totpSecretStoreRegistry.ts:54`) | **volatile** — perdu au redémarrage | ✅ dev / tests |
612
- | `drizzle` | `@nodefony/drizzle` (`registerStores.ts:320`) | durable, partagé entre pods | ✅ 3 dialectes SQL |
613
- | `mongoose` | `@nodefony/mongoose` (`registerStores.ts`) | durable, partagé entre pods | ✅ MongoDB |
614
- | `redis` | — | — | ⏳ manquant, à venir |
609
+ | Backend | Enregistré par | Durabilité | État |
610
+ | ---------- | ------------------------------------------------------------------------ | ----------------------------------- | -------------------- |
611
+ | `memory` | builtin (`totpSecretStoreRegistry.ts`) | **volatile** — perdu au redémarrage | ✅ dev / tests |
612
+ | `drizzle` | `@nodefony/drizzle` (`registerStores.ts#registerDrizzleFrameworkStores`) | durable, partagé entre pods | ✅ 3 dialectes SQL |
613
+ | `mongoose` | `@nodefony/mongoose` (`registerStores.ts`) | durable, partagé entre pods | ✅ MongoDB |
614
+ | `redis` | — | — | ⏳ manquant, à venir |
615
615
 
616
616
  Une application peut donc activer le 2FA **entièrement sur MongoDB**, sans charger d'ORM SQL : une
617
617
  application choisit son ORM, elle ne choisit pas de se passer du second facteur.
@@ -632,12 +632,12 @@ primaire (`_id` = `userId`) — donc l'unicité existe dès le premier document,
632
632
  en tâche de fond.
633
633
 
634
634
  Côté `drizzle`, les **trois dialectes** sont portés — `TOTP_PORTED` vaut l'ensemble des dialectes
635
- (`registerStores.ts:94`) : SQLite, PostgreSQL, MySQL/MariaDB. Le store n'écrit **aucun SQL natif**,
636
- tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts:44`)
635
+ (`registerStores.ts#TOTP_PORTED`) : SQLite, PostgreSQL, MySQL/MariaDB. Le store n'écrit **aucun SQL natif**,
636
+ tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts#DrizzleTotpSecretStore`)
637
637
  — c'est ce qui rend la portabilité gratuite.
638
638
 
639
639
  **Comment le backend est choisi.** `store: "auto"` (le défaut) suit l'infra déclarée puis les
640
- adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:137`) :
640
+ adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts#TotpService.#resolveStore`) :
641
641
 
642
642
  1. `NF_STORE` (override global de banc de charge), s'il est enregistré ici ;
643
643
  2. infra base de données déclarée (`NF_DATABASE_URL`) → `drizzle` ;
@@ -645,7 +645,7 @@ adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:137`) :
645
645
  4. sinon **repli `memory`, annoncé** — jamais silencieux.
646
646
 
647
647
  Un `store` **explicite** introuvable, en revanche, ne se replie pas : `CRITIC` en dev, boot avorté en
648
- production (`totp.ts:174`). Une faute de frappe ne dégrade jamais la sécurité en douce.
648
+ production (`totp.ts#TotpService.#resolveStore`). Une faute de frappe ne dégrade jamais la sécurité en douce.
649
649
 
650
650
  ## Le listing paginé des enrôlements
651
651
 
@@ -653,48 +653,48 @@ Question d'exploitation : « quelle est la **couverture** 2FA, et qui est resté
653
653
  confirmation ? » Un secret jamais confirmé ne protège personne, et c'est invisible depuis la fiche
654
654
  d'un seul utilisateur.
655
655
 
656
- `ITotpSecretStore.listPage()` (`ITotpSecretStore.ts:85`) répond, avec trois garanties :
656
+ `ITotpSecretStore.listPage()` (`ITotpSecretStore.ts#ITotpSecretStore.listPage`) répond, avec trois garanties :
657
657
 
658
658
  - **pagination native au store** — jamais de parcours complet en mémoire ; l'ordre est contractuel
659
659
  (`createdAt` DESC, départagé par `userId` ASC) ;
660
660
  - **filtres appliqués côté backend** — `confirmed` (activés / en attente) et `q` (préfixe d'`userId`,
661
- donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts:176`) ;
662
- - **la vue ne peut pas porter de secret** — `ITotpEnrollmentSummary` (`ITotpSecretStore.ts:16`)
661
+ donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts#DrizzleTotpSecretStore.#listCriteria`) ;
662
+ - **la vue ne peut pas porter de secret** — `ITotpEnrollmentSummary` (`ITotpSecretStore.ts#ITotpEnrollmentSummary`)
663
663
  n'a ni `secretEnc` ni les condensats de récupération, seulement leur **nombre**
664
664
  (`recoveryCodesLeft`).
665
665
 
666
666
  Ce dernier point est une garantie **de contrat**, pas une redaction faite à l'affichage : quel que
667
667
  soit le backend, ces champs ne peuvent pas remonter par ce chemin, même si un appelant les demandait
668
- (`toTotpEnrollment()`, `MemoryTotpSecretStore.ts:19`). C'est ce qu'exerce le banc de contrat partagé.
668
+ (`toTotpEnrollment()`, `MemoryTotpSecretStore.ts#toTotpEnrollment`). C'est ce qu'exerce le banc de contrat partagé.
669
669
 
670
- `countEnrollments()` (`ITotpSecretStore.ts:96`) donne le KPI de couverture sans énumérer une seule
670
+ `countEnrollments()` (`ITotpSecretStore.ts#ITotpSecretStore.countEnrollments`) donne le KPI de couverture sans énumérer une seule
671
671
  ligne.
672
672
 
673
673
  ## 🧰 API publique
674
674
 
675
675
  Tout est exporté depuis `@nodefony/security` — signatures complètes dans `.ai/symbols.json`.
676
676
 
677
- **Le service** (`TotpService`, `totp.ts:74`), résolu par nom dans le container (`"totp"`) :
677
+ **Le service** (`TotpService`, `totp.ts#TotpService`), résolu par nom dans le container (`"totp"`) :
678
678
 
679
- | Méthode | Rôle |
680
- | ------------------------------------- | ------------------------------------------------------------ |
681
- | `isEnabled()` (`totp.ts:254`) | 2FA opérationnel (activé en config **et** boot réussi). |
682
- | `beginEnrollment()` (`totp.ts:259`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
683
- | `confirmEnrollment()` (`totp.ts:264`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
684
- | `verifyLogin()` (`totp.ts:300`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
685
- | `disable()` (`totp.ts:316`) | Retire secret et codes. |
686
- | `status()` (`totp.ts:331`) | `{ enabled, pending, recoveryCodesRemaining }`. |
687
- | `isEnabledFor()` (`totp.ts:358`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
688
- | `listPage()` (`totp.ts:342`) | Page d'enrôlements (data plane admin). |
689
- | `countEnrollments()` (`totp.ts:353`) | Compte filtré, sans énumération. |
679
+ | Méthode | Rôle |
680
+ | --------------------------------------------------------------- | ------------------------------------------------------------ |
681
+ | `isEnabled()` (`totp.ts#TotpService.isEnabled`) | 2FA opérationnel (activé en config **et** boot réussi). |
682
+ | `beginEnrollment()` (`totp.ts#TotpService.beginEnrollment`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
683
+ | `confirmEnrollment()` (`totp.ts#TotpService.confirmEnrollment`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
684
+ | `verifyLogin()` (`totp.ts#TotpService.verifyLogin`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
685
+ | `disable()` (`totp.ts#TotpService.disable`) | Retire secret et codes. |
686
+ | `status()` (`totp.ts#TotpService.status`) | `{ enabled, pending, recoveryCodesRemaining }`. |
687
+ | `isEnabledFor()` (`totp.ts#TotpService.isEnabledFor`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
688
+ | `listPage()` (`totp.ts#TotpService.listPage`) | Page d'enrôlements (data plane admin). |
689
+ | `countEnrollments()` (`totp.ts#TotpService.countEnrollments`) | Compte filtré, sans énumération. |
690
690
 
691
691
  **Les opérations pures**, si tu veux le 2FA **sans** le service (test, script, autre transport) —
692
692
  elles prennent leurs dépendances en argument : `beginTotpEnrollment()`, `confirmTotpEnrollment()`,
693
- `verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts:200`).
693
+ `verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts#totpStatus`).
694
694
 
695
695
  **Les primitives crypto**, pour écrire un client ou un banc de test : `totpCode()`
696
- (`totpCrypto.ts:174`), `base32Decode()` (`totpCrypto.ts:81`), `deriveTotpKey()`
697
- (`totpCipher.ts:33`).
696
+ (`totpCrypto.ts#totpCode`), `base32Decode()` (`totpCrypto.ts#base32Decode`), `deriveTotpKey()`
697
+ (`totpCipher.ts#deriveTotpKey`).
698
698
 
699
699
  ```typescript
700
700
  // Calculer le code attendu côté « application d'authentification » — exactement
@@ -706,7 +706,7 @@ const code = totpCode(base32Decode(secretBase32), { epochMs: Date.now() });
706
706
  ## 🧩 Extension — brancher son propre store
707
707
 
708
708
  Le registre découple le cœur du backend : implémente `ITotpSecretStore`
709
- (`ITotpSecretStore.ts:69`), enregistre la fabrique, sélectionne-la en config.
709
+ (`ITotpSecretStore.ts#ITotpSecretStore`), enregistre la fabrique, sélectionne-la en config.
710
710
 
711
711
  ```typescript
712
712
  import { registerTotpStore, type ITotpSecretStore } from "@nodefony/security";
@@ -724,19 +724,19 @@ vérifie que ta projection n'expose ni secret ni condensat.
724
724
 
725
725
  ## 📜 Normes appliquées
726
726
 
727
- | Domaine | Norme | Ancrage dans le code |
728
- | --------------------------- | ------------------------ | ---------------------------------------------------------- |
729
- | TOTP (algorithme) | RFC 6238 §4 | `totpCode()` (`totpCrypto.ts:174`) |
730
- | HOTP + troncature dynamique | RFC 4226 §5.3 | `hotp()` (`totpCrypto.ts:129`), masque `0x7f` (`:141`) |
731
- | Taille du secret (≥ 128 b) | RFC 4226 R6 | `TOTP_DEFAULTS.secretBytes` = 20 (`totpCrypto.ts:33`) |
732
- | Fenêtre de dérive | RFC 6238 §5.2 | `verifyTotp()` (`totpCrypto.ts:207`) |
733
- | Anti-rejeu du code | RFC 6238 §5.2 | garde `lastUsedStep` (`totpOperations.ts:173`) |
734
- | Encodage du secret | RFC 4648 (base32) | `base32Encode()` (`totpCrypto.ts:58`) |
735
- | Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts:54`) |
736
- | Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts:30`) |
737
- | Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts:330`) |
738
- | Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:297`) |
739
- | Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts:164-172`) |
727
+ | Domaine | Norme | Ancrage dans le code |
728
+ | --------------------------- | ------------------------ | --------------------------------------------------------------------------- |
729
+ | TOTP (algorithme) | RFC 6238 §4 | `totpCode()` (`totpCrypto.ts#totpCode`) |
730
+ | HOTP + troncature dynamique | RFC 4226 §5.3 | `hotp()` (`totpCrypto.ts#hotp`), masque `0x7f` (`totpCrypto.ts#hotp`) |
731
+ | Taille du secret (≥ 128 b) | RFC 4226 R6 | `TOTP_DEFAULTS.secretBytes` = 20 (`totpCrypto.ts#TOTP_DEFAULTS`) |
732
+ | Fenêtre de dérive | RFC 6238 §5.2 | `verifyTotp()` (`totpCrypto.ts#verifyTotp`) |
733
+ | Anti-rejeu du code | RFC 6238 §5.2 | garde `lastUsedStep` (`totpOperations.ts#verifyTotpLogin`) |
734
+ | Encodage du secret | RFC 4648 (base32) | `base32Encode()` (`totpCrypto.ts#base32Encode`) |
735
+ | Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts#deriveKey`) |
736
+ | Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts#IV_BYTES`) |
737
+ | Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts#generateRecoveryCodes`) |
738
+ | Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts#AuthFlow.completeMfaLogin`) |
739
+ | Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts#SessionAuthController.me`) |
740
740
 
741
741
  Les **vecteurs de test de la RFC 6238 (Appendix B)** sont rejoués en test sur les trois fonctions de
742
742
  hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
@@ -745,18 +745,18 @@ hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
745
745
 
746
746
  Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas.
747
747
 
748
- - **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts:642`)
748
+ - **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts#AuthFlow.#resolveTotp`)
749
749
  résout le service **une seule fois** puis met le résultat en cache. Service absent ⇒ `null` ⇒
750
750
  **zéro accès au store**, zéro allocation par login.
751
751
  - **Aucun coût par requête** : le TOTP n'est pas un authenticator du firewall, il ne s'exécute donc
752
752
  jamais dans le pipeline HTTP/WS.
753
753
  - **Allocation paresseuse du store** : la `Map` de `MemoryTotpSecretStore`
754
- (`MemoryTotpSecretStore.ts:74`) n'existe que si le 2FA est activé — le service ne construit rien
755
- quand `totp.enabled` est `false` (`totp.ts:101`).
754
+ (`MemoryTotpSecretStore.ts#MemoryTotpSecretStore.#byUser`) n'existe que si le 2FA est activé — le service ne construit rien
755
+ quand `totp.enabled` est `false` (`totp.ts#TotpService.#build`).
756
756
  - **Le coût réel d'une vérification** : ≤ `2·window + 1` HMAC (3 par défaut) + un déchiffrement
757
757
  AES-GCM. De l'ordre de la microseconde — négligeable devant le hachage Argon2id du mot de passe
758
758
  qui l'a précédé.
759
- - **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts:241`)
759
+ - **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts#TotpService.#shutdown`)
760
760
  le déclenche à `onTerminate` — aucune écriture en attente perdue.
761
761
 
762
762
  ## 📡 Observabilité — Studio
@@ -771,12 +771,12 @@ Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas
771
771
  Le data plane admin correspondant, gardé par `ROLE_NODEFONY_ADMIN` :
772
772
 
773
773
  - `GET /nodefony/security/api/totp/list` — couverture 2FA paginée, `?q=` = préfixe d'`userId`
774
- (`SecurityAdminApi.ts:601`).
774
+ (`SecurityAdminApi.ts#createSecurityAdminApi`).
775
775
  Réponse **honnête** si le 2FA est désactivé : `{ enabled: false, items: [] }`, jamais une erreur —
776
776
  la console doit pouvoir afficher « 2FA désactivé ».
777
- - `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts:748`).
777
+ - `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts#createSecurityAdminApi`).
778
778
  - `POST /nodefony/security/api/users/{id}/totp/disable` — reset admin, **audité**
779
- (`SecurityAdminApi.ts:767`).
779
+ (`SecurityAdminApi.ts#createSecurityAdminApi`).
780
780
 
781
781
  Ces deux routes répondent `503` si le service `users` manque : sans lui, l'`id` ne se traduit pas en
782
782
  clé de sujet, et agir quand même rendrait un faux succès.
@@ -784,29 +784,29 @@ clé de sujet, et agir quand même rendrait un faux succès.
784
784
  L'admin peut **désactiver**, jamais **activer** pour autrui : le secret se scanne sur l'appareil de
785
785
  l'utilisateur, lui seul peut l'armer.
786
786
 
787
- Côté journal d'audit, cinq actions tracent le cycle : `login.mfa_required` (`authFlow.ts:177`),
788
- `login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts:232`), `login.failure` avec
789
- `reason: "mfa_invalid"` (`authFlow.ts:291`), `user.totp_enabled` quand le titulaire active son
790
- facteur (`totp.ts:290`), et `user.totp_disabled` — par le titulaire (`totp.ts:321`,
791
- `viaAdmin: false`) ou par un administrateur (`SecurityAdminApi.ts:789`, `viaAdmin: true`). Les
787
+ Côté journal d'audit, cinq actions tracent le cycle : `login.mfa_required` (`authFlow.ts#AuthFlow.login`),
788
+ `login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts#AuthFlow.login`), `login.failure` avec
789
+ `reason: "mfa_invalid"` (`authFlow.ts#AuthFlow.completeMfaLogin`), `user.totp_enabled` quand le titulaire active son
790
+ facteur (`totp.ts#TotpService.confirmEnrollment`), et `user.totp_disabled` — par le titulaire (`totp.ts#TotpService.disable`,
791
+ `viaAdmin: false`) ou par un administrateur (`SecurityAdminApi.ts#createSecurityAdminApi`, `viaAdmin: true`). Les
792
792
  deux gestes du titulaire portent l'IP, le navigateur et l'identifiant de la requête.
793
793
 
794
794
  ## ⚠️ Pièges (symptôme → cause → correction)
795
795
 
796
- | Symptôme | Cause (dans le code) | Correction |
797
- | ------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------ |
798
- | 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` |
799
- | 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 |
800
- | Secrets perdus à chaque redémarrage | Store résolu en `memory` (aucun adapter durable chargé) | Charger `@nodefony/drizzle` ou déclarer `NF_DATABASE_URL` |
801
- | 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 |
802
- | Le QR est scanné mais aucun code ne passe | `digits`/`algorithm` non standard, ignorés par l'app | Rester en `SHA1` / 6 chiffres |
803
- | `202` au login au lieu de `200` | Comportement **attendu** : second facteur requis | Enchaîner sur `POST …/auth/login/totp` |
804
- | `…/auth/me` rend `{ user: null }` 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 |
805
- | `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts:297`) | Respecter `Retry-After` — attendu sous attaque |
806
- | `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts:21`) | Vérifier `totp.enabled` + la clé + les logs de boot |
807
- | Utilisateur bloqué, plus aucun code | Codes de récupération épuisés | Reset admin via `…/users/{id}/totp/disable`, puis ré-enrôlement |
808
- | Même code accepté deux fois | Impossible — anti-rejeu `lastUsedStep` (`totpOperations.ts:173`) | — |
809
- | 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 |
796
+ | Symptôme | Cause (dans le code) | Correction |
797
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
798
+ | 2FA inactif en production, `CRITIC` au boot | `totp.encryptionKey` absente — fail-closed (`totp.ts#TotpService.#resolveKey`) | `npx nodefony security:secrets`, puis câbler `ctx.env.NF_TOTP_KEY` |
799
+ | 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 |
800
+ | Secrets perdus à chaque redémarrage | Store résolu en `memory` (aucun adapter durable chargé) | Charger `@nodefony/drizzle` ou déclarer `NF_DATABASE_URL` |
801
+ | 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 |
802
+ | Le QR est scanné mais aucun code ne passe | `digits`/`algorithm` non standard, ignorés par l'app | Rester en `SHA1` / 6 chiffres |
803
+ | `202` au login au lieu de `200` | Comportement **attendu** : second facteur requis | Enchaîner sur `POST …/auth/login/totp` |
804
+ | `…/auth/me` rend `{ user: null }` juste après le mot de passe | L'identité n'est posée qu'après le 2ᵉ facteur (`authFlow.ts#AuthFlow.login`) | Terminer le step-up |
805
+ | `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts#AuthFlow.completeMfaLogin`) | Respecter `Retry-After` — attendu sous attaque |
806
+ | `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts#ITotpManager.isEnabled`) | Vérifier `totp.enabled` + la clé + les logs de boot |
807
+ | Utilisateur bloqué, plus aucun code | Codes de récupération épuisés | Reset admin via `…/users/{id}/totp/disable`, puis ré-enrôlement |
808
+ | Même code accepté deux fois | Impossible — anti-rejeu `lastUsedStep` (`totpOperations.ts#verifyTotpLogin`) | — |
809
+ | Code de récupération réutilisable | Impossible — retiré du stock à l'usage (`totpOperations.ts#verifyTotpLogin`) | Régénérer un lot en ré-enrôlant si le stock est bas |
810
810
 
811
811
  ## 🧪 Tests & couverture
812
812