@nodefony/security 10.0.0-alpha.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 (258) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +182 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +151 -0
  6. package/dist/nodefony/command/security-secrets.js +158 -0
  7. package/dist/nodefony/command/security-token.js +335 -0
  8. package/dist/nodefony/command/security-user-add.js +131 -0
  9. package/dist/nodefony/command/security-user-delete.js +102 -0
  10. package/dist/nodefony/command/security-user-list.js +77 -0
  11. package/dist/nodefony/config/config.js +366 -0
  12. package/dist/nodefony/config/defineModuleConfig.js +35 -0
  13. package/dist/nodefony/contracts/IAccessVoter.js +13 -0
  14. package/dist/nodefony/contracts/IApiKey.js +1 -0
  15. package/dist/nodefony/contracts/IAuditEvent.js +1 -0
  16. package/dist/nodefony/contracts/IAuditStore.js +1 -0
  17. package/dist/nodefony/contracts/IAuthenticator.js +1 -0
  18. package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
  19. package/dist/nodefony/contracts/IFirewall.js +1 -0
  20. package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
  21. package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
  22. package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
  23. package/dist/nodefony/contracts/ISecuredArea.js +1 -0
  24. package/dist/nodefony/contracts/IToken.js +1 -0
  25. package/dist/nodefony/contracts/ITokenStore.js +1 -0
  26. package/dist/nodefony/contracts/ITotpSecret.js +1 -0
  27. package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
  28. package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
  29. package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
  30. package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
  31. package/dist/nodefony/contracts/IWebhookStore.js +1 -0
  32. package/dist/nodefony/contracts/index.js +2 -0
  33. package/dist/nodefony/errors/AccessDeniedError.js +14 -0
  34. package/dist/nodefony/errors/ApiKeyError.js +21 -0
  35. package/dist/nodefony/errors/AuthenticationError.js +14 -0
  36. package/dist/nodefony/errors/CsrfError.js +23 -0
  37. package/dist/nodefony/errors/InvalidTargetError.js +39 -0
  38. package/dist/nodefony/errors/SsrfError.js +17 -0
  39. package/dist/nodefony/errors/ThrottledError.js +21 -0
  40. package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
  41. package/dist/nodefony/errors/WebAuthnError.js +21 -0
  42. package/dist/nodefony/errors/index.js +9 -0
  43. package/dist/nodefony/service/accessTokenVerifier.js +77 -0
  44. package/dist/nodefony/service/apiKeys.js +310 -0
  45. package/dist/nodefony/service/auditService.js +145 -0
  46. package/dist/nodefony/service/authFlow.js +332 -0
  47. package/dist/nodefony/service/authorization.js +95 -0
  48. package/dist/nodefony/service/cors.js +81 -0
  49. package/dist/nodefony/service/csrf.js +97 -0
  50. package/dist/nodefony/service/firewall.js +699 -0
  51. package/dist/nodefony/service/oauth2.js +153 -0
  52. package/dist/nodefony/service/securityHeaders.js +80 -0
  53. package/dist/nodefony/service/tokenService.js +486 -0
  54. package/dist/nodefony/service/totp.js +209 -0
  55. package/dist/nodefony/service/webAuthn.js +343 -0
  56. package/dist/nodefony/service/webhooks.js +539 -0
  57. package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
  58. package/dist/nodefony/src/SecuredArea.js +51 -0
  59. package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
  60. package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
  61. package/dist/nodefony/src/admin/adminAudit.js +37 -0
  62. package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
  63. package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
  64. package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
  65. package/dist/nodefony/src/audit/auditBridge.js +82 -0
  66. package/dist/nodefony/src/audit/auditFilters.js +60 -0
  67. package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
  68. package/dist/nodefony/src/audit/readAuditContext.js +24 -0
  69. package/dist/nodefony/src/audit/recordAudit.js +16 -0
  70. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
  71. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
  72. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
  73. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
  74. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
  75. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
  76. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
  77. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
  78. package/dist/nodefony/src/authenticator/bearer.js +2 -0
  79. package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
  80. package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
  81. package/dist/nodefony/src/crypto/secretCipher.js +79 -0
  82. package/dist/nodefony/src/csp.js +54 -0
  83. package/dist/nodefony/src/csrfToken.js +65 -0
  84. package/dist/nodefony/src/net/ssrfGuard.js +130 -0
  85. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +65 -0
  87. package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
  88. package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
  89. package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
  90. package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
  91. package/dist/nodefony/src/sessionIdentity.js +35 -0
  92. package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
  93. package/dist/nodefony/src/token/AnonymousToken.js +40 -0
  94. package/dist/nodefony/src/token/JwtKeystore.js +160 -0
  95. package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
  96. package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
  97. package/dist/nodefony/src/token/UserToken.js +67 -0
  98. package/dist/nodefony/src/token/jwtRuntime.js +19 -0
  99. package/dist/nodefony/src/token/secretFile.js +134 -0
  100. package/dist/nodefony/src/token/tokenCriteria.js +35 -0
  101. package/dist/nodefony/src/token/tokenFilters.js +72 -0
  102. package/dist/nodefony/src/token/tokenSort.js +40 -0
  103. package/dist/nodefony/src/token/tokenStatus.js +35 -0
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
  106. package/dist/nodefony/src/totp/totpCipher.js +30 -0
  107. package/dist/nodefony/src/totp/totpCrypto.js +226 -0
  108. package/dist/nodefony/src/totp/totpOperations.js +129 -0
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
  110. package/dist/nodefony/src/voter/RoleVoter.js +32 -0
  111. package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
  112. package/dist/nodefony/src/voter/voterRegistry.js +20 -0
  113. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
  114. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
  115. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
  116. package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
  117. package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
  118. package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
  119. package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
  120. package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
  121. package/dist/nodefony/src/webhook/webhookSort.js +48 -0
  122. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
  123. package/dist/types/index.d.ts +157 -0
  124. package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
  125. package/dist/types/nodefony/command/security-token.d.ts +44 -0
  126. package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
  127. package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
  128. package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
  129. package/dist/types/nodefony/config/config.d.ts +295 -0
  130. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  131. package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
  132. package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
  133. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
  134. package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
  135. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
  136. package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
  137. package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
  138. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
  139. package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
  140. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
  141. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
  142. package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
  143. package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
  144. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
  145. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
  146. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
  147. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
  148. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
  149. package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
  150. package/dist/types/nodefony/contracts/index.d.ts +9 -0
  151. package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
  152. package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
  153. package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
  154. package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
  155. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
  156. package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
  157. package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
  158. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
  159. package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
  160. package/dist/types/nodefony/errors/index.d.ts +8 -0
  161. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
  162. package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
  163. package/dist/types/nodefony/service/auditService.d.ts +30 -0
  164. package/dist/types/nodefony/service/authFlow.d.ts +123 -0
  165. package/dist/types/nodefony/service/authorization.d.ts +33 -0
  166. package/dist/types/nodefony/service/cors.d.ts +48 -0
  167. package/dist/types/nodefony/service/csrf.d.ts +57 -0
  168. package/dist/types/nodefony/service/firewall.d.ts +148 -0
  169. package/dist/types/nodefony/service/oauth2.d.ts +66 -0
  170. package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
  171. package/dist/types/nodefony/service/tokenService.d.ts +103 -0
  172. package/dist/types/nodefony/service/totp.d.ts +58 -0
  173. package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
  174. package/dist/types/nodefony/service/webhooks.d.ts +160 -0
  175. package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
  176. package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
  177. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
  178. package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
  179. package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
  180. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
  181. package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
  182. package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
  183. package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
  184. package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
  185. package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
  186. package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
  187. package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
  188. package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
  189. package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
  190. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
  191. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
  192. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
  193. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
  194. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
  195. package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
  196. package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
  197. package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
  198. package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
  199. package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
  200. package/dist/types/nodefony/src/csp.d.ts +39 -0
  201. package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
  202. package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
  203. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
  204. package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
  205. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
  206. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
  207. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
  208. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
  209. package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
  210. package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
  211. package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
  212. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
  213. package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
  214. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
  215. package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
  216. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
  217. package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
  218. package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
  219. package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
  220. package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
  221. package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
  222. package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
  223. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
  224. package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
  225. package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
  226. package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
  227. package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
  228. package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
  229. package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
  230. package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
  231. package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
  232. package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
  233. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
  234. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
  235. package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
  236. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
  237. package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
  238. package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
  239. package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
  240. package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
  241. package/docs/api-keys.md +691 -0
  242. package/docs/audit.md +751 -0
  243. package/docs/authenticators.md +487 -0
  244. package/docs/authorization.md +497 -0
  245. package/docs/cors.md +497 -0
  246. package/docs/csrf.md +392 -0
  247. package/docs/external-jwt.md +181 -0
  248. package/docs/firewall.md +546 -0
  249. package/docs/headers.md +616 -0
  250. package/docs/index.md +207 -0
  251. package/docs/lexique.md +190 -0
  252. package/docs/oauth2.md +575 -0
  253. package/docs/obtenir-un-jeton.md +225 -0
  254. package/docs/tokens.md +520 -0
  255. package/docs/totp.md +804 -0
  256. package/docs/webauthn.md +733 -0
  257. package/docs/webhooks.md +1016 -0
  258. package/package.json +83 -0
package/docs/totp.md ADDED
@@ -0,0 +1,804 @@
1
+ ---
2
+ title: "TOTP — second facteur 2FA (RFC 6238) chiffré au repos"
3
+ navTitle: TOTP
4
+ lang: fr
5
+ module: "@nodefony/security"
6
+ topic: totp
7
+ coverageModule: security
8
+ coverageFiles: "totpCrypto,totpOperations,totpCipher,MemoryTotpSecretStore"
9
+ section: "Sécurité"
10
+ audience: [developer]
11
+ tags:
12
+ [
13
+ security,
14
+ totp,
15
+ 2fa,
16
+ mfa,
17
+ rfc6238,
18
+ rfc4226,
19
+ hkdf,
20
+ aes-gcm,
21
+ step-up,
22
+ recovery-codes,
23
+ ]
24
+ version: "doc"
25
+ status: stable
26
+ updated: 2026-07-19
27
+ source: "src/packages/@nodefony/security/docs/totp.md"
28
+ ---
29
+
30
+ # TOTP — le second facteur à 6 chiffres
31
+
32
+ > Un mot de passe volé suffit à se connecter. Le TOTP ajoute une **deuxième preuve** : un code à
33
+ > 6 chiffres qui change toutes les 30 secondes, calculé **des deux côtés** (serveur + application
34
+ > d'authentification) à partir d'un secret partagé et de l'horloge — aucun code ne circule sur le
35
+ > réseau. Nodefony l'implémente selon la **RFC 6238**, avec le secret **chiffré au repos** (jamais
36
+ > haché : le serveur doit le relire), un **anti-rejeu**, et des **codes de récupération** pour le jour
37
+ > où le téléphone tombe dans l'eau.
38
+
39
+ 📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **TOTP**
40
+
41
+ ## 🧠 Le modèle mental — un secret partagé, une horloge commune
42
+
43
+ Le serveur et le téléphone ne se parlent **jamais** après l'enrôlement. Ils partagent un secret `K`,
44
+ regardent la même horloge, et calculent le même code chacun de leur côté. Se connecter, c'est prouver
45
+ qu'on détient `K` — sans jamais le transmettre.
46
+
47
+ ```mermaid
48
+ flowchart TD
49
+ subgraph ENR["1 · Enrôlement (une fois)"]
50
+ E1["POST …/totp/enroll<br/>secret aléatoire 160 bits"] --> E2["secret CHIFFRÉ au repos<br/>AES-256-GCM · confirmedAt = null"]
51
+ E2 --> E3["QR otpauth:// scanné par l'app<br/>(secret en clair = SEUL moment)"]
52
+ E3 --> E4["POST …/totp/confirm (1ᵉʳ code)<br/>→ 2FA actif + codes de récupération"]
53
+ end
54
+ subgraph LOG["2 · Login (à chaque connexion)"]
55
+ L1["POST …/auth/login<br/>identifiant + mot de passe"] --> L2{"2FA activé ?"}
56
+ L2 -->|non| OK1["200 · session ouverte"]
57
+ L2 -->|oui| L3["202 mfaRequired<br/>défi PENDING · identité NON posée"]
58
+ L3 --> L4["POST …/auth/login/totp<br/>code à 6 chiffres"]
59
+ L4 --> L5{"code TOTP valide ?<br/>fenêtre ±1 pas · jamais rejoué"}
60
+ L5 -->|oui| OK2["200 · session ouverte"]
61
+ L5 -->|non| L6{"code de récupération ?"}
62
+ L6 -->|oui| OK3["200 · code consommé (usage unique)"]
63
+ L6 -->|non| KO["401 · message uniforme"]
64
+ end
65
+ ```
66
+
67
+ Tant que le second facteur n'est pas validé, **l'identité n'est pas établie** : `session.user` reste
68
+ vide et le Zero Trust du firewall répond 401 sur tout le reste (`AuthFlow.login()`,
69
+ `authFlow.ts:170`).
70
+
71
+ ## 📖 Lexique
72
+
73
+ | Terme | Sens |
74
+ | ------------------------ | ----------------------------------------------------------------------------------------------------------- |
75
+ | **TOTP** | _Time-based One-Time Password_ (RFC 6238) : code dérivé d'un secret **et** du temps. |
76
+ | **HOTP** | _HMAC-based One-Time Password_ (RFC 4226) : la brique sous TOTP (compteur au lieu du temps). |
77
+ | **2FA / MFA** | Authentification à deux (ou plusieurs) facteurs : ce que je sais **+** ce que je détiens. |
78
+ | **Secret partagé `K`** | Les 20 octets aléatoires communs au serveur et à l'application d'authentification. |
79
+ | **Pas / tranche `T`** | Le numéro de la période de 30 s en cours — `T = ⌊epoch / 30⌋`. C'est le compteur HOTP. |
80
+ | **Fenêtre de dérive** | Tolérance d'horloge : ±1 pas (±30 s) de part et d'autre. |
81
+ | **Anti-rejeu** | Un code déjà accepté ne peut plus resservir, même dans sa fenêtre de validité. |
82
+ | **Step-up** | Le second facteur est demandé **au login** (pas à chaque requête) — élévation depuis un 1ᵉʳ facteur validé. |
83
+ | **Code de récupération** | Code de secours à usage unique, imprimé une fois, pour un appareil perdu. |
84
+ | **HKDF** | _HMAC-based Key Derivation Function_ (RFC 5869) : fabrique une clé AES à partir d'un secret de config. |
85
+ | **AES-256-GCM** | Chiffrement **authentifié** : confidentialité + détection de toute altération. |
86
+ | **base32** | Encodage RFC 4648 du secret — lisible, saisissable à la main, compris par toutes les apps. |
87
+ | **`otpauth://`** | Format d'URI (_Key Uri Format_) encodé dans le QR code d'enrôlement. |
88
+
89
+ ## Qu'est-ce que le TOTP — et quelle faille il bloque
90
+
91
+ **La faille.** Un mot de passe est un secret **statique** : phishing, fuite de base, réutilisation
92
+ d'un mot de passe compromis ailleurs — une fois volé, il ouvre la porte, et personne ne le remarque.
93
+
94
+ **La parade.** Exiger une **seconde preuve d'une autre nature** : non plus « ce que je sais » mais
95
+ « ce que je détiens ». L'attaquant qui a le mot de passe n'a pas le téléphone.
96
+
97
+ **Pourquoi TOTP plutôt qu'un SMS.** Le code se calcule **hors ligne**, sur l'appareil :
98
+
99
+ - pas de SMS interceptable (SIM swap, réseau SS7) — le NIST déconseille le SMS comme facteur ;
100
+ - pas de dépendance à un opérateur ni à une connexion réseau ;
101
+ - interopérable avec toutes les applications existantes (Google Authenticator, Authy, 1Password,
102
+ Bitwarden…) via l'URI `otpauth://`.
103
+
104
+ **Ce que le TOTP ne fait PAS.** Il n'est **pas résistant au phishing** : un site miroir qui demande
105
+ le code en temps réel peut le rejouer dans les 30 secondes. Pour cette menace-là, la réponse est
106
+ [WebAuthn / passkeys](webauthn.md), où la preuve est liée cryptographiquement au domaine. Le TOTP
107
+ reste le second facteur **universel** — celui qui marche sans matériel dédié.
108
+
109
+ ## La vision Nodefony — coquille fine, logique pure, secret chiffré
110
+
111
+ Trois partis pris, tous vérifiables dans le code.
112
+
113
+ **1. La logique est PURE, le service n'est qu'une prise de courant.** `TotpService` (`totp.ts:71`)
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
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
118
+ serveur, sans base, avec une horloge injectée** — et c'est pour ça qu'elle est couverte à ~100 %.
119
+
120
+ **2. Le secret est CHIFFRÉ, jamais haché.** Un mot de passe se hache (à sens unique) parce que le
121
+ serveur n'a qu'à le **comparer**. Un secret TOTP, lui, doit être **relu en clair** à chaque
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
124
+ différence de traitement, pas une négligence.
125
+
126
+ **3. Le TOTP n'est pas un authenticator du firewall — c'est un step-up de login.** Il n'apparaît
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
129
+ WebAuthn et OAuth : le firewall n'a qu'un seul mécanisme à connaître, la **session**.
130
+
131
+ > [!IMPORTANT]
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:44`). 2FA désactivé
134
+ > ⇒ service absent ⇒ le login nominal **ne paie strictement rien** (`AuthFlow.#resolveTotp()`,
135
+ > `authFlow.ts:455`).
136
+
137
+ ## 🚀 Démarrage rapide
138
+
139
+ **Le besoin.** Ton application a des comptes à mot de passe. Tu veux que chaque utilisateur puisse
140
+ activer un second facteur depuis sa page « ma sécurité », et que le login l'exige ensuite.
141
+
142
+ ### 1. Générer la clé de chiffrement
143
+
144
+ Le secret TOTP est chiffré au repos → il faut une clé **stable** (sinon les secrets deviennent
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`) :
147
+
148
+ ```bash
149
+ npx nodefony security:secrets --write
150
+ # 🔐 Secrets security — 3 étapes, 3 FICHIERS
151
+ # 1. Fichier .env.local — les valeurs
152
+ # ✓ écrit dans .env.local (NF_TOTP_KEY, NF_WEBHOOK_KEY, NF_CSRF_SECRET)
153
+ # 2. Fichier env.ts — la déclaration typée
154
+ # 3. Fichier nodefony.config.ts — le câblage vers le module security
155
+ ```
156
+
157
+ Elle produit 32 octets aléatoires en base64 (`randomBytes(32)`, `security-secrets.ts:122`) et
158
+ **n'écrase jamais** une valeur existante — une rotation reste un geste manuel et conscient.
159
+
160
+ ### 2. Déclarer puis câbler la clé
161
+
162
+ ```typescript
163
+ // env.ts — SEUL lecteur de process.env (catalogue typé, validé au boot).
164
+ // nodefony.config.ts — `ctx.env` EST ce catalogue (typé par le paramètre générique).
165
+ import { defineConfig, defineEnv, envString, use } from "nodefony";
166
+
167
+ export const env = defineEnv({
168
+ // Clé de chiffrement du secret 2FA au repos — générée par `nodefony security:secrets`.
169
+ NF_TOTP_KEY: envString({ optional: true }),
170
+ });
171
+
172
+ export default defineConfig<typeof env>((ctx) => ({
173
+ modules: [
174
+ "@nodefony/http",
175
+ "@nodefony/framework",
176
+ // La persistance des secrets 2FA passe par un backend durable : charger
177
+ // l'adapter suffit, il s'enregistre tout seul (`store: "auto"` le trouve).
178
+ "@nodefony/drizzle",
179
+ use("@nodefony/security", {
180
+ totp: {
181
+ // Nom affiché dans l'app d'authentification (label du QR). Omis = nom de l'app.
182
+ issuer: "Mon App",
183
+ // Absente en production = 2FA DÉSACTIVÉ (fail-closed, jamais une clé jetable).
184
+ encryptionKey: ctx.env.NF_TOTP_KEY,
185
+ },
186
+ }),
187
+ ],
188
+ }));
189
+ ```
190
+
191
+ ### 3. Les endpoints sont FOURNIS — tu n'écris aucun controller
192
+
193
+ `mountTotpRoutes()` (`TotpController.ts:154`) monte quatre routes self-service, **et seulement si**
194
+ le service `totp` existe (security chargé + 2FA activé) — sinon zéro surface, 404 :
195
+
196
+ | Route | Corps | Réponse |
197
+ | ------------------------------------------ | ---------- | ---------------------------------------------- |
198
+ | `POST /nodefony/security/api/totp/enroll` | — | `{ secretBase32, otpauthUri }` — affichés 1× |
199
+ | `POST /nodefony/security/api/totp/confirm` | `{ code }` | `{ recoveryCodes }` — affichés 1× |
200
+ | `POST /nodefony/security/api/totp/disable` | — | `{ ok: true }` |
201
+ | `GET /nodefony/security/api/totp/status` | — | `{ enabled, pending, recoveryCodesRemaining }` |
202
+
203
+ Et côté login, deux routes du flux de session BFF (`mountSessionAuthRoutes()`,
204
+ `SessionAuthController.ts:166`) :
205
+
206
+ | Route | Corps | Réponse |
207
+ | --------------------------------------------- | ------------------------ | ------------------------------------------ |
208
+ | `POST /nodefony/security/api/auth/login` | `{ username, password }` | `200` + identité, **ou** `202 mfaRequired` |
209
+ | `POST /nodefony/security/api/auth/login/totp` | `{ code }` | `200` + identité, `401`, ou `429` |
210
+
211
+ > [!WARNING]
212
+ > Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts:52`) : elles vivent dans la
213
+ > zone data plane et exigent une session BFF. Le sujet est **toujours** l'utilisateur courant, lu
214
+ > depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:138`) — jamais un
215
+ > paramètre : on n'active ni ne désactive le 2FA d'autrui (anti-IDOR).
216
+
217
+ ### 4. Ce qu'on observe
218
+
219
+ ```bash
220
+ # 1) ENRÔLEMENT — session BFF requise. Le secret n'apparaît QU'ICI.
221
+ curl -s -b /tmp/jar -X POST http://localhost:5151/nodefony/security/api/totp/enroll
222
+ # {"secretBase32":"JBSWY3DPEHPK3PXPJBSWY3DP",
223
+ # "otpauthUri":"otpauth://totp/Mon%20App:alice?secret=JBSWY3DPEHPK3PXPJBSWY3DP&issuer=Mon+App&algorithm=SHA1&digits=6&period=30"}
224
+ # ↑ c'est cette URI que l'UI transforme en QR code. Le 2FA n'est PAS encore actif.
225
+
226
+ # 2) CONFIRMATION — le 1ᵉʳ code lu dans l'app prouve que le scan a marché.
227
+ curl -s -b /tmp/jar -H 'Content-Type: application/json' \
228
+ -d '{"code":"492039"}' http://localhost:5151/nodefony/security/api/totp/confirm
229
+ # {"recoveryCodes":["K7M2P-9XQ4R","T3VBN-2HJKD", … 10 au total …]}
230
+ # ↑ affichés UNE seule fois : au repos, seuls leurs condensats sont gardés.
231
+
232
+ curl -s -b /tmp/jar http://localhost:5151/nodefony/security/api/totp/status
233
+ # {"enabled":true,"pending":false,"recoveryCodesRemaining":10}
234
+ ```
235
+
236
+ ```bash
237
+ # 3) LOGIN — le mot de passe ne suffit plus : 202, pas 200.
238
+ curl -si -c /tmp/jar2 -H 'Content-Type: application/json' \
239
+ -d '{"username":"alice","password":"…"}' \
240
+ http://localhost:5151/nodefony/security/api/auth/login
241
+ # HTTP/1.1 202 Accepted
242
+ # {"mfaRequired":true,"methods":["totp"]}
243
+
244
+ # 4) L'identité n'est PAS établie tant que le code n'est pas donné.
245
+ curl -s -o /dev/null -w '%{http_code}\n' -b /tmp/jar2 \
246
+ http://localhost:5151/nodefony/security/api/auth/me
247
+ # 401
248
+
249
+ # 5) Le second facteur ouvre la session.
250
+ curl -si -b /tmp/jar2 -c /tmp/jar2 -H 'Content-Type: application/json' \
251
+ -d '{"code":"492039"}' \
252
+ http://localhost:5151/nodefony/security/api/auth/login/totp | head -1
253
+ # HTTP/1.1 200 OK
254
+ ```
255
+
256
+ ## 🏗️ Les deux cérémonies — enrôlement, puis vérification
257
+
258
+ ### L'enrôlement se fait en deux temps (et c'est volontaire)
259
+
260
+ Générer un secret ne suffit pas : il faut **prouver que l'utilisateur l'a bien enregistré** avant
261
+ d'exiger le second facteur — sinon on l'enferme dehors dès la prochaine connexion.
262
+
263
+ ```mermaid
264
+ sequenceDiagram
265
+ autonumber
266
+ participant U as Utilisateur
267
+ participant UI as Console « ma sécurité »
268
+ participant C as TotpController
269
+ participant S as totpOperations
270
+ participant DB as Store de secrets
271
+
272
+ U->>UI: « Activer la 2FA »
273
+ UI->>C: POST …/totp/enroll (cookie de session)
274
+ C->>S: beginTotpEnrollment(userId, account)
275
+ S->>S: secret aléatoire 160 bits
276
+ S->>S: encryptSecret(secret, clé AES)
277
+ S->>DB: save({ secretEnc, confirmedAt: null })
278
+ S-->>UI: { secretBase32, otpauthUri }
279
+ UI-->>U: QR code + clé en clair (SEUL moment)
280
+ U->>U: scanne avec son app d'authentification
281
+ U->>UI: saisit le 1ᵉʳ code affiché
282
+ UI->>C: POST …/totp/confirm { code }
283
+ C->>S: confirmTotpEnrollment(userId, code)
284
+ S->>DB: findByUser → secretEnc
285
+ S->>S: decryptSecret + verifyTotp(code)
286
+ S->>DB: update({ confirmedAt, recoveryCodes hachés, lastUsedStep })
287
+ S-->>UI: { recoveryCodes } en clair, 1×
288
+ UI-->>U: « Notez ces codes de secours »
289
+ ```
290
+
291
+ Ce que le code garantit à chaque étape :
292
+
293
+ - **`beginTotpEnrollment()`** (`totpOperations.ts:72`) écrit le secret **déjà chiffré** avec
294
+ `confirmedAt: null` — l'état « en attente ». Il est **idempotent** : rappeler l'enrôlement écrase
295
+ simplement le précédent non confirmé (l'utilisateur qui a raté son scan recommence, sans support).
296
+ - **`confirmTotpEnrollment()`** (`totpOperations.ts:112`) refuse si aucun enrôlement n'est en cours
297
+ ou s'il est déjà confirmé, et **reste en attente** si le code est faux — aucun état intermédiaire
298
+ bancal.
299
+ - Le pas qui a servi à confirmer est marqué **consommé** (`lastUsedStep: res.step`,
300
+ `totpOperations.ts:142`) : le code de confirmation n'est pas rejouable comme premier code de login.
301
+
302
+ > [!TIP]
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`).
305
+ > La cause fine reste côté serveur.
306
+
307
+ ### La vérification au login
308
+
309
+ ```mermaid
310
+ sequenceDiagram
311
+ autonumber
312
+ participant U as Navigateur
313
+ participant A as AuthFlow
314
+ participant T as TotpService
315
+ participant DB as Store de secrets
316
+
317
+ U->>A: login(identifiant, mot de passe)
318
+ A->>A: throttle NIST, puis vérification du mot de passe
319
+ A->>T: isEnabledFor(user)
320
+ T->>DB: findByUser
321
+ T-->>A: true
322
+ A->>A: session.set("mfa:pending", user) — identité NON posée
323
+ A-->>U: 202 { mfaRequired: true, methods: ["totp"] }
324
+ U->>A: completeMfaLogin(code)
325
+ A->>A: throttle sur l'identité en attente
326
+ A->>T: verifyLogin(user, code)
327
+ T->>DB: findByUser → secretEnc
328
+ T->>T: decrypt + verifyTotp (fenêtre ±window)
329
+ T->>T: anti-rejeu : step > lastUsedStep ?
330
+ T->>DB: update({ lastUsedStep, lastUsedAt })
331
+ T-->>A: { ok: true, method: "totp" }
332
+ A->>A: défi consommé, puis session ouverte (ID régénéré)
333
+ A-->>U: 200 { user }
334
+ ```
335
+
336
+ Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:254`) :
337
+
338
+ 1. **Le défi vit en session, pas dans l'URL ni dans un jeton client** — clé `mfa:pending`
339
+ (`authFlow.ts:18`), posée par le login, **consommée** avant l'ouverture de session
340
+ (`authFlow.ts:298`).
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
343
+ quelques minutes sans lui. Trop de tentatives → `429` + `Retry-After`.
344
+ 3. **Un échec ne détruit pas le défi** — l'utilisateur qui s'est trompé de chiffre ressaisit ; il
345
+ n'a pas à refaire son mot de passe.
346
+
347
+ ### La fenêtre de dérive et l'anti-rejeu
348
+
349
+ Les deux horloges ne sont jamais parfaitement synchrones. `verifyTotp()` (`totpCrypto.ts:207`)
350
+ balaie donc les tranches `T-window … T+window` et compare **en temps constant**
351
+ (`timingSafeEqual`, `totpCrypto.ts:227`) — une comparaison naïve fuirait le préfixe correct par le
352
+ temps de réponse.
353
+
354
+ | `window` | Tolérance réelle | Codes acceptés simultanément | Verdict |
355
+ | -------- | ---------------- | ---------------------------- | ---------------------------------------- |
356
+ | `0` | aucune | 1 | Casse dès quelques secondes de dérive. |
357
+ | `1` | ±30 s | 3 | **Défaut** — la valeur de la RFC 6238. |
358
+ | `2` | ±60 s | 5 | Surface d'attaque ×1,7 pour peu de gain. |
359
+
360
+ Le contrepoids obligatoire, c'est l'**anti-rejeu** : la tranche qui a validé est mémorisée
361
+ (`ITotpSecret.lastUsedStep`, `ITotpSecret.ts:36`), et tout code d'une tranche **≤** à la dernière
362
+ consommée est refusé par la garde `lastUsedStep` (`totpOperations.ts:173`). Un code intercepté —
363
+ épaule, proxy, phishing en temps réel — est donc **mort dès qu'il a servi une fois**.
364
+
365
+ > [!WARNING]
366
+ > La fenêtre tolère la dérive d'horloge, elle ne la corrige pas. Un serveur sans NTP finit par
367
+ > dériver au-delà de ±30 s et **tous** les codes sont refusés, sans message explicite.
368
+
369
+ ## 🔐 Le secret au repos — HKDF puis AES-256-GCM
370
+
371
+ ### Pourquoi une dérivation de clé plutôt que la clé de config directement
372
+
373
+ La valeur de `totp.encryptionKey` est une chaîne d'application : passphrase, hex, base64, longueur
374
+ quelconque. AES-256 exige exactement **32 octets de haute entropie**. `deriveKey()`
375
+ (`secretCipher.ts:54`) passe donc le matériel dans **HKDF-SHA256** (RFC 5869) :
376
+
377
+ - **Déterministe** — tous les pods d'un cluster dérivent la **même** clé du même secret : un secret
378
+ écrit par un pod se relit par les autres, sans réplication de clé.
379
+ - **Séparation de domaine** — chaque brique dérive avec un `salt`/`info` distinct. Le contexte TOTP
380
+ est figé (`TOTP_DERIVATION`, `totpCipher.ts:23`) : un blob de webhook ne se déchiffre **jamais**
381
+ avec la clé TOTP, par construction, même si la clé maître de config est la même.
382
+ - **Longueur libre en entrée** — une passphrase courte ne devient jamais une clé AES faible.
383
+
384
+ ### Le format du blob
385
+
386
+ `encryptSecret()` (`secretCipher.ts:77`) produit une chaîne opaque, préfixée par sa version :
387
+
388
+ ```
389
+ gcm1.<base64url( iv‖tag‖ciphertext )>
390
+ └ 12 o ┘└16 o┘
391
+ ```
392
+
393
+ - **IV de 12 octets tiré à chaque chiffrement** (`secretCipher.ts:30`) — deux enrôlements du même
394
+ secret donnent deux blobs différents.
395
+ - **GCM = chiffrement authentifié** : le tag de 16 octets fait échouer `decryptSecret()`
396
+ (`secretCipher.ts:90`) si le blob a été altéré **ou** si la clé est la mauvaise. GCM ne distingue
397
+ pas les deux cas, par construction — toute manipulation du secret stocké est donc détectée.
398
+ - **Préfixe versionné `gcm1`** : une rotation d'algorithme future pourra cohabiter avec les secrets
399
+ existants.
400
+
401
+ Le store, lui, ne voit que des octets : il ne déchiffre jamais rien (`ITotpSecret.secretEnc`,
402
+ `ITotpSecret.ts:18`).
403
+
404
+ ### La politique de clé — bruyante en dev, fail-closed en production
405
+
406
+ `TotpService.#resolveKey()` (`totp.ts:204`) tranche au boot :
407
+
408
+ | Situation | Environnement | Comportement |
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`). |
413
+ | `store: "memory"` en prod | production | `WARNING` — secrets volatils, comptes verrouillés au reboot. |
414
+
415
+ Le refus en production est délibéré : une clé éphémère chiffrerait des secrets **illisibles au
416
+ redémarrage suivant** et sur les autres pods — les utilisateurs seraient enfermés dehors, sans
417
+ message. Mieux vaut un 2FA absent et bruyant qu'un 2FA qui casse silencieusement en pleine nuit.
418
+
419
+ > [!CAUTION]
420
+ > Ne **jamais** modifier `TOTP_DERIVATION` (`totpCipher.ts:23`). Changer son sel ou son `info` rend
421
+ > illisibles **tous** les secrets déjà stockés — chaque utilisateur devra ré-enrôler.
422
+
423
+ ## Les codes de récupération — perdre son téléphone
424
+
425
+ Un second facteur crée un risque neuf : **s'enfermer dehors**. Les codes de récupération sont la
426
+ sortie de secours — le NIST les classe comme _look-up secrets_ (SP 800-63B §5.1.2).
427
+
428
+ **Comment ils sont fabriqués** (`generateRecoveryCodes()`, `totpCrypto.ts:330`) :
429
+
430
+ - 10 codes par défaut (`totp.recoveryCodes`), au format lisible `XXXXX-XXXXX` ;
431
+ - alphabet **sans caractères ambigus** — ni `I`, ni `L`, ni `O`, ni `U` (`totpCrypto.ts:269`) : on les
432
+ recopie à la main, souvent sous stress ;
433
+ - ~50 bits d'aléa chacun — non devinable, mais ce n'est **pas** un mot de passe humain.
434
+
435
+ **Comment ils sont stockés** : en condensat `sha256` (`hashRecoveryCode()`, `totpCrypto.ts:347`),
436
+ jamais en clair. Un `sha256` simple suffit ici, précisément parce que l'entrée est **aléatoire** (une
437
+ attaque par dictionnaire n'a rien à mordre) — contrairement à un mot de passe, qui exige Argon2id.
438
+
439
+ **Comment ils sont consommés** : au login, si le code présenté n'est pas un TOTP valide,
440
+ `verifyTotpLogin()` cherche une correspondance parmi les condensats — **en temps constant sur chaque
441
+ entrée**, et sans court-circuit à la première trouvaille (`matchRecoveryCode()`,
442
+ `totpCrypto.ts:356`). Le code trouvé est **retiré de la liste** (`totpOperations.ts:186`) : usage
443
+ unique, strictement.
444
+
445
+ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totpCrypto.ts:272`) :
446
+ `k7m2p9xq4r` vaut `K7M2P-9XQ4R`.
447
+
448
+ > [!TIP]
449
+ > `recoveryCodesRemaining` (route `…/totp/status`) est l'indicateur qui compte : c'est lui qui dit
450
+ > **qui va se verrouiller** au prochain changement d'appareil. Il est exposé jusque dans la vue
451
+ > admin, sans jamais exposer les condensats.
452
+
453
+ ## ⚙️ Configuration et mises en situation
454
+
455
+ La section `totp` du schéma Zod (`config.ts:1107`) — validée au boot, donc une valeur hors bornes
456
+ échoue **au démarrage**, pas au premier login :
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`). |
469
+
470
+ ### Situation 1 — un utilisateur active la 2FA sur son compte
471
+
472
+ C'est le cas nominal, et il ne demande **aucune configuration** au-delà de la clé : les routes
473
+ self-service sont déjà là, la console Studio les consomme déjà (`/nodefony/profile`).
474
+
475
+ ```typescript
476
+ use("@nodefony/security", {
477
+ totp: { encryptionKey: process.env.NF_TOTP_KEY },
478
+ });
479
+ ```
480
+
481
+ | L'utilisateur fait… | Ce qu'il observe |
482
+ | -------------------------------- | -------------------------------------------------------------------- |
483
+ | clique « Activer » | QR code + clé base32 copiable, 2FA **pas encore actif** |
484
+ | saisit le 1ᵉʳ code | 10 codes de récupération à noter, badge « 2FA active » |
485
+ | se déconnecte puis se reconnecte | après le mot de passe : écran « code à 6 chiffres » (réponse `202`) |
486
+ | quitte la page sans confirmer | rien n'est armé — un `enroll` suivant écrase simplement le brouillon |
487
+
488
+ ### Situation 2 — exiger une re-vérification avant une action sensible
489
+
490
+ Le second facteur validé au login ne dit rien de **qui est devant l'écran dix minutes plus tard**
491
+ (poste laissé ouvert, session volée). Pour une suppression de compte ou une rotation de clés, on
492
+ redemande le code : c'est le _sudo mode_.
493
+
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`) :
496
+
497
+ ```typescript
498
+ import { controller, Controller, Post } from "@nodefony/framework";
499
+ import type { TotpService } from "@nodefony/security";
500
+
501
+ @controller("/api/secure/account")
502
+ class DangerController extends Controller {
503
+ @Post("/delete")
504
+ async remove() {
505
+ const totp = this.get<TotpService>("totp");
506
+ const code = (this.queryPost as { code?: unknown }).code;
507
+ // Zone protégée : le firewall a déjà authentifié. On exige la PREUVE FRAÎCHE.
508
+ if (!totp?.isEnabled() || typeof code !== "string") {
509
+ return this.renderJson({ error: "2FA required" }, 403);
510
+ }
511
+ const proof = await totp.verifyLogin(this.context.user as string, code);
512
+ if (!proof.ok) {
513
+ return this.renderJson({ error: "Invalid code" }, 403);
514
+ }
515
+ // … l'action destructrice ici …
516
+ return this.renderJson({ ok: true });
517
+ }
518
+ }
519
+ ```
520
+
521
+ L'anti-rejeu joue en ta faveur : le code utilisé pour cette action ne pourra plus être rejoué pour
522
+ une autre. Variante **sans** TOTP, purement déclarative, si la re-saisie du mot de passe te suffit :
523
+ une zone firewall en `mode: "all"` avec `["session", "userpassword"]` — voir
524
+ [firewall](firewall.md).
525
+
526
+ ### Situation 3 — le contre-exemple piégeux : « durcir » les paramètres
527
+
528
+ La tentation est grande de monter `digits: 8` et `algorithm: "SHA512"` pour « renforcer ». C'est un
529
+ piège d'interopérabilité :
530
+
531
+ ```typescript
532
+ totp: { algorithm: "SHA1", digits: 6 }, // ✅ lu par toutes les apps
533
+ totp: { algorithm: "SHA512", digits: 8 }, // ❌ Google Authenticator ignore ces paramètres
534
+ ```
535
+
536
+ L'URI `otpauth://` transporte bien `algorithm` et `digits` (`buildOtpauthUri()`,
537
+ `totpCrypto.ts:253`), mais plusieurs applications grand public les **ignorent** et calculent en
538
+ `SHA1`/6 chiffres. Résultat : le QR est scanné, l'app affiche un code… systématiquement refusé, sans
539
+ que rien ne semble anormal. Le gain de sécurité réel est par ailleurs nul — l'anti-rejeu et le
540
+ throttle bornent déjà les tentatives bien avant l'espace des codes.
541
+
542
+ ## L'entité de persistance — un secret par utilisateur
543
+
544
+ Le modèle est volontairement minimal : **clé naturelle = `userId`**, `save` est un upsert
545
+ (`ITotpSecretStore`, `ITotpSecretStore.ts:69`). Pas d'identifiant de ligne, pas d'index secondaire —
546
+ tout accès passe par la clé primaire.
547
+
548
+ Spécification logique de la table (`TOTP_SECRET_TABLE_SPEC`, `totpSecretEntity.ts:36`), déclinée par
549
+ dialecte via le colKit :
550
+
551
+ | Colonne | Rôle | SQLite | PostgreSQL | MySQL / MariaDB |
552
+ | --------------- | ----------------------------------------------- | ------------------ | ---------- | --------------- |
553
+ | `userId` **PK** | Propriétaire (clé naturelle) | `text` | `text` | `varchar(512)` |
554
+ | `secretEnc` | Secret `K` **chiffré** (blob opaque) | `text` | `text` | `text` |
555
+ | `algorithm` | `SHA1` / `SHA256` / `SHA512` | `text` | `text` | `text` |
556
+ | `digits` | Longueur du code | `integer` | `integer` | `int` |
557
+ | `period` | Durée d'un code (s) | `integer` | `integer` | `int` |
558
+ | `recoveryCodes` | Condensats des codes **non consommés** | `text` (mode json) | `jsonb` | `json` |
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` |
561
+ | `createdAt` | Création (epoch ms) | `integer` | `bigint` | `bigint` |
562
+ | `lastUsedAt` | Dernier usage réussi (epoch ms) ou `null` | `integer` | `bigint` | `bigint` |
563
+
564
+ Deux pièges de lecture, signalés dans l'entité elle-même :
565
+
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`).
568
+ - Un epoch en millisecondes **déborde** un `integer` 32 bits → `bigint` en PostgreSQL et MySQL
569
+ (SQLite, lui, a des INTEGER 64 bits).
570
+
571
+ ### Les backends disponibles — et ceux qui manquent
572
+
573
+ | Backend | Enregistré par | Durabilité | État |
574
+ | ---------- | --------------------------------------------- | ----------------------------------- | -------------------- |
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 |
578
+ | `redis` | — | — | ⏳ manquant, à venir |
579
+
580
+ Ces deux absences sont des **manques**, pas des choix de périmètre (`MIGRATION_STATUS.md`, P7.11) —
581
+ mais elles se comblent à deux régimes différents.
582
+
583
+ `redis` le portera **en opt-in explicite, jamais choisi par `auto`** — exactement le régime des
584
+ passkeys qu'il porte déjà. Un secret TOTP est de la même famille qu'un credential passkey : une
585
+ petite valeur, durable, relue à chaque authentification, dont la perte verrouille l'utilisateur
586
+ dehors. Porter l'un et refuser l'autre au nom du « cache évincible » serait incohérent : le risque
587
+ est identique, et il est déjà assumé, avec son avertissement — sur Redis, la persistance devient la
588
+ responsabilité de l'exploitant (AOF, pas d'éviction sur ces clés).
589
+
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.
596
+
597
+ 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`)
600
+ — c'est ce qui rend la portabilité gratuite.
601
+
602
+ **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
+
605
+ 1. `NF_STORE` (override global de banc de charge), s'il est enregistré ici ;
606
+ 2. infra base de données déclarée (`NF_DATABASE_URL`) → `drizzle` ;
607
+ 3. sinon, backend local persistant chargé → `drizzle` (SQLite) ;
608
+ 4. sinon **repli `memory`, annoncé** — jamais silencieux.
609
+
610
+ 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
+
613
+ ## Le listing paginé des enrôlements
614
+
615
+ Question d'exploitation : « quelle est la **couverture** 2FA, et qui est resté bloqué en attente de
616
+ confirmation ? » Un secret jamais confirmé ne protège personne, et c'est invisible depuis la fiche
617
+ d'un seul utilisateur.
618
+
619
+ `ITotpSecretStore.listPage()` (`ITotpSecretStore.ts:85`) répond, avec trois garanties :
620
+
621
+ - **pagination native au store** — jamais de parcours complet en mémoire ; l'ordre est contractuel
622
+ (`createdAt` DESC, départagé par `userId` ASC) ;
623
+ - **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
+ - **la vue ne peut pas porter de secret** — `ITotpEnrollmentSummary` (`ITotpSecretStore.ts:16`)
626
+ n'a ni `secretEnc` ni les condensats de récupération, seulement leur **nombre**
627
+ (`recoveryCodesLeft`).
628
+
629
+ Ce dernier point est une garantie **de contrat**, pas une redaction faite à l'affichage : quel que
630
+ 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
+
633
+ `countEnrollments()` (`ITotpSecretStore.ts:90`) donne le KPI de couverture sans énumérer une seule
634
+ ligne.
635
+
636
+ ## 🧰 API publique
637
+
638
+ Tout est exporté depuis `@nodefony/security` — signatures complètes dans `.ai/symbols.json`.
639
+
640
+ **Le service** (`TotpService`, `totp.ts:71`), résolu par nom dans le container (`"totp"`) :
641
+
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. |
653
+
654
+ **Les opérations pures**, si tu veux le 2FA **sans** le service (test, script, autre transport) —
655
+ elles prennent leurs dépendances en argument : `beginTotpEnrollment()`, `confirmTotpEnrollment()`,
656
+ `verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts:72`).
657
+
658
+ **Les primitives crypto**, pour écrire un client ou un banc de test : `totpCode()`
659
+ (`totpCrypto.ts:174`), `base32Decode()` (`totpCrypto.ts:81`), `deriveTotpKey()`
660
+ (`totpCipher.ts:33`).
661
+
662
+ ```typescript
663
+ // Calculer le code attendu côté « application d'authentification » — exactement
664
+ // ce que fait le banc e2e drizzle pour piloter un vrai login.
665
+ import { totpCode, base32Decode } from "@nodefony/security";
666
+ const code = totpCode(base32Decode(secretBase32), { epochMs: Date.now() });
667
+ ```
668
+
669
+ ## 🧩 Extension — brancher son propre store
670
+
671
+ Le registre découple le cœur du backend : implémente `ITotpSecretStore`
672
+ (`ITotpSecretStore.ts:69`), enregistre la fabrique, sélectionne-la en config.
673
+
674
+ ```typescript
675
+ import { registerTotpStore, type ITotpSecretStore } from "@nodefony/security";
676
+
677
+ registerTotpStore("mon-backend", ({ container, config }) => {
678
+ return new MonTotpStore(container, config.totp.period);
679
+ });
680
+ // puis : use("@nodefony/security", { totp: { store: "mon-backend" } })
681
+ ```
682
+
683
+ Six méthodes à tenir : `findByUser`, `save` (upsert), `update` (patch **partiel** — un champ absent
684
+ ne doit **pas** être écrasé à `null`), `delete`, `listPage`, `countEnrollments`. Le contrat de
685
+ listing se prouve en branchant le banc partagé sur ton store (voir la section Tests) — c'est lui qui
686
+ vérifie que ta projection n'expose ni secret ni condensat.
687
+
688
+ ## 📜 Normes appliquées
689
+
690
+ | Domaine | Norme | Ancrage dans le code |
691
+ | --------------------------- | ------------------------ | ------------------------------------------------------ |
692
+ | TOTP (algorithme) | RFC 6238 §4 | `totpCode()` (`totpCrypto.ts:174`) |
693
+ | HOTP + troncature dynamique | RFC 4226 §5.3 | `hotp()` (`totpCrypto.ts:129`), masque `0x7f` (`:141`) |
694
+ | Taille du secret (≥ 128 b) | RFC 4226 R6 | `TOTP_DEFAULTS.secretBytes` = 20 (`totpCrypto.ts:33`) |
695
+ | Fenêtre de dérive | RFC 6238 §5.2 | `verifyTotp()` (`totpCrypto.ts:207`) |
696
+ | Anti-rejeu du code | RFC 6238 §5.2 | garde `lastUsedStep` (`totpOperations.ts:173`) |
697
+ | Encodage du secret | RFC 4648 (base32) | `base32Encode()` (`totpCrypto.ts:58`) |
698
+ | Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts:54`) |
699
+ | Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts:30`) |
700
+ | 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
+ | Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts:145`) |
703
+
704
+ Les **vecteurs de test de la RFC 6238 (Appendix B)** sont rejoués en test sur les trois fonctions de
705
+ hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
706
+
707
+ ## ⚡ Performance & mémoire
708
+
709
+ Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas.
710
+
711
+ - **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts:455`)
712
+ résout le service **une seule fois** puis met le résultat en cache. Service absent ⇒ `null` ⇒
713
+ **zéro accès au store**, zéro allocation par login.
714
+ - **Aucun coût par requête** : le TOTP n'est pas un authenticator du firewall, il ne s'exécute donc
715
+ jamais dans le pipeline HTTP/WS.
716
+ - **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`).
719
+ - **Le coût réel d'une vérification** : ≤ `2·window + 1` HMAC (3 par défaut) + un déchiffrement
720
+ AES-GCM. De l'ordre de la microseconde — négligeable devant le hachage Argon2id du mot de passe
721
+ qui l'a précédé.
722
+ - **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts:234`)
723
+ le déclenche à `onTerminate` — aucune écriture en attente perdue.
724
+
725
+ ## 📡 Observabilité — Studio
726
+
727
+ | Écran | Ce qu'il montre |
728
+ | ------------------------------------- | --------------------------------------------------------------------------------- |
729
+ | **Profil** `/nodefony/profile` | Carte 2FA self-service : statut, activation par QR, désactivation. |
730
+ | **Utilisateur** `/nodefony/users/:id` | Vue admin : statut 2FA + **réinitialisation** (appareil perdu). Pas d'enrôlement. |
731
+ | **Stores** `/nodefony/stores` | Backend résolu pour la brique `totp` + emplacement physique. |
732
+ | **Login** `/nodefony/login` | La phase « code à 6 chiffres » du step-up (réponse `202`). |
733
+
734
+ Le data plane admin correspondant, gardé par `ROLE_NODEFONY_ADMIN` :
735
+
736
+ - `GET /nodefony/security/api/totp/list` — couverture 2FA paginée (`SecurityAdminApi.ts:606`).
737
+ Réponse **honnête** si le 2FA est désactivé : `{ enabled: false, items: [] }`, jamais une erreur —
738
+ la console doit pouvoir afficher « 2FA désactivé ».
739
+ - `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts:658`).
740
+ - `POST /nodefony/security/api/users/{id}/totp/disable` — reset admin, **audité**
741
+ (`SecurityAdminApi.ts:683`).
742
+
743
+ L'admin peut **désactiver**, jamais **activer** pour autrui : le secret se scanne sur l'appareil de
744
+ l'utilisateur, lui seul peut l'armer.
745
+
746
+ 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
+ `reason: "mfa_invalid"` (`authFlow.ts:291`), et `user.totp_disabled` côté admin
749
+ (`SecurityAdminApi.ts:707`).
750
+
751
+ ## ⚠️ Pièges (symptôme → cause → correction)
752
+
753
+ | Symptôme | Cause (dans le code) | Correction |
754
+ | ------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------ |
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
+ | 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
+ | Secrets perdus à chaque redémarrage | Store résolu en `memory` (aucun adapter durable chargé) | Charger `@nodefony/drizzle` ou déclarer `NF_DATABASE_URL` |
758
+ | 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
+ | Le QR est scanné mais aucun code ne passe | `digits`/`algorithm` non standard, ignorés par l'app | Rester en `SHA1` / 6 chiffres |
760
+ | `202` au login au lieu de `200` | Comportement **attendu** : second facteur requis | Enchaîner sur `POST …/auth/login/totp` |
761
+ | `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 |
764
+ | Utilisateur bloqué, plus aucun code | Codes de récupération épuisés | Reset admin via `…/users/{id}/totp/disable`, puis ré-enrôlement |
765
+ | Même code accepté deux fois | Impossible — anti-rejeu `lastUsedStep` (`totpOperations.ts:173`) | — |
766
+ | 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 |
767
+
768
+ ## 🧪 Tests & couverture
769
+
770
+ Quatre familles couvrent la brique — les **chiffres exacts vivent dans la carte de l'aperçu**
771
+ (régénérée par `gen-counters.mjs` depuis vitest, jamais figée ici) :
772
+
773
+ - **unitaires** — `totpCrypto` (vecteurs RFC 6238 Appendix B sur SHA1/256/512, troncature, base32,
774
+ fenêtre, format `otpauth://`, codes de récupération), `totpOperations` (enrôlement, confirmation,
775
+ anti-rejeu, récupération, statut), `totpCipher` (round-trip AES-GCM, altération détectée,
776
+ dérivation HKDF), `totpSecretStore` (CRUD par `userId`, snapshot/restore), `mfaStepUp` (le
777
+ step-up de login : défi PENDING, identité non posée, throttle) ;
778
+ - **banc de contrat** — `totpPaginationContract` (`tests/support/totpPaginationContract.ts`) :
779
+ seed déterministe de 10 enrôlements, exécuté à l'identique sur **tous** les backends. Il porte une
780
+ exigence de **sécurité**, pas seulement de pagination : un backend qui élargirait sa projection
781
+ (secret ou condensats) échoue ici ;
782
+ - **intégration** — `totp-store-sqlite` : le même banc branché sur `DrizzleTotpSecretStore` ;
783
+ - **E2E base réelle** — `totp-flow-e2e` rejoue le **flux complet** (enrôlement → confirmation →
784
+ login anti-rejeu → code de récupération → désactivation) sur le store Drizzle, pas un CRUD isolé ;
785
+ `totp-store-postgres.e2e` et `totp-store-mysql.e2e` rejouent le contrat sur PostgreSQL et
786
+ MySQL/MariaDB réels (gatés par `NF_PG_URL` / `NF_MYSQL_URL` — sans eux, ces suites **se skippent**,
787
+ et un skip compte comme vert).
788
+
789
+ **Ce qui manque, dit franchement** : aucun test d'**attaque** dédié (`*.attack.test.ts`) sur le
790
+ TOTP — brute-force du code sous throttle, énumération par la latence, rejeu inter-pod — et aucun
791
+ test de **charge/mémoire** propre à la brique. La coquille de boot `service/totp.ts` (I/O de
792
+ câblage) n'est pas couverte en unitaire ; c'est le banc e2e qui l'exerce indirectement.
793
+
794
+ Skills utiles : `nodefony-security-review` (mode red-team, pour combler les tests d'attaque),
795
+ `nodefony-load-test` (charge). Couverture : `npm run coverage` dans `@nodefony/security`.
796
+
797
+ ## 🔗 Pour aller plus loin
798
+
799
+ - ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
800
+ - Le facteur **résistant au phishing**, la suite logique → [WebAuthn / passkeys](webauthn.md)
801
+ - Où le step-up s'insère (zones, Zero Trust, `mode: "all"`) → [Firewall](firewall.md)
802
+ - Le 1ᵉʳ facteur : mot de passe, Basic, throttle NIST → [Authenticators](authenticators.md)
803
+ - Ce que l'audit enregistre du cycle 2FA → [Autorisation](authorization.md)
804
+ - Termes croisés (facteur, step-up, BFF, Zero Trust) → [Lexique](lexique.md)