@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.
- package/LICENSE +544 -0
- package/README.md +182 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +151 -0
- package/dist/nodefony/command/security-secrets.js +158 -0
- package/dist/nodefony/command/security-token.js +335 -0
- package/dist/nodefony/command/security-user-add.js +131 -0
- package/dist/nodefony/command/security-user-delete.js +102 -0
- package/dist/nodefony/command/security-user-list.js +77 -0
- package/dist/nodefony/config/config.js +366 -0
- package/dist/nodefony/config/defineModuleConfig.js +35 -0
- package/dist/nodefony/contracts/IAccessVoter.js +13 -0
- package/dist/nodefony/contracts/IApiKey.js +1 -0
- package/dist/nodefony/contracts/IAuditEvent.js +1 -0
- package/dist/nodefony/contracts/IAuditStore.js +1 -0
- package/dist/nodefony/contracts/IAuthenticator.js +1 -0
- package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
- package/dist/nodefony/contracts/IFirewall.js +1 -0
- package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
- package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
- package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
- package/dist/nodefony/contracts/ISecuredArea.js +1 -0
- package/dist/nodefony/contracts/IToken.js +1 -0
- package/dist/nodefony/contracts/ITokenStore.js +1 -0
- package/dist/nodefony/contracts/ITotpSecret.js +1 -0
- package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
- package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
- package/dist/nodefony/contracts/IWebhookStore.js +1 -0
- package/dist/nodefony/contracts/index.js +2 -0
- package/dist/nodefony/errors/AccessDeniedError.js +14 -0
- package/dist/nodefony/errors/ApiKeyError.js +21 -0
- package/dist/nodefony/errors/AuthenticationError.js +14 -0
- package/dist/nodefony/errors/CsrfError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +39 -0
- package/dist/nodefony/errors/SsrfError.js +17 -0
- package/dist/nodefony/errors/ThrottledError.js +21 -0
- package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
- package/dist/nodefony/errors/WebAuthnError.js +21 -0
- package/dist/nodefony/errors/index.js +9 -0
- package/dist/nodefony/service/accessTokenVerifier.js +77 -0
- package/dist/nodefony/service/apiKeys.js +310 -0
- package/dist/nodefony/service/auditService.js +145 -0
- package/dist/nodefony/service/authFlow.js +332 -0
- package/dist/nodefony/service/authorization.js +95 -0
- package/dist/nodefony/service/cors.js +81 -0
- package/dist/nodefony/service/csrf.js +97 -0
- package/dist/nodefony/service/firewall.js +699 -0
- package/dist/nodefony/service/oauth2.js +153 -0
- package/dist/nodefony/service/securityHeaders.js +80 -0
- package/dist/nodefony/service/tokenService.js +486 -0
- package/dist/nodefony/service/totp.js +209 -0
- package/dist/nodefony/service/webAuthn.js +343 -0
- package/dist/nodefony/service/webhooks.js +539 -0
- package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
- package/dist/nodefony/src/SecuredArea.js +51 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
- package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
- package/dist/nodefony/src/admin/adminAudit.js +37 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
- package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
- package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
- package/dist/nodefony/src/audit/auditBridge.js +82 -0
- package/dist/nodefony/src/audit/auditFilters.js +60 -0
- package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
- package/dist/nodefony/src/audit/readAuditContext.js +24 -0
- package/dist/nodefony/src/audit/recordAudit.js +16 -0
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
- package/dist/nodefony/src/authenticator/bearer.js +2 -0
- package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
- package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
- package/dist/nodefony/src/crypto/secretCipher.js +79 -0
- package/dist/nodefony/src/csp.js +54 -0
- package/dist/nodefony/src/csrfToken.js +65 -0
- package/dist/nodefony/src/net/ssrfGuard.js +130 -0
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
- package/dist/nodefony/src/oauth/providers/github.js +65 -0
- package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
- package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
- package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
- package/dist/nodefony/src/sessionIdentity.js +35 -0
- package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
- package/dist/nodefony/src/token/AnonymousToken.js +40 -0
- package/dist/nodefony/src/token/JwtKeystore.js +160 -0
- package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
- package/dist/nodefony/src/token/UserToken.js +67 -0
- package/dist/nodefony/src/token/jwtRuntime.js +19 -0
- package/dist/nodefony/src/token/secretFile.js +134 -0
- package/dist/nodefony/src/token/tokenCriteria.js +35 -0
- package/dist/nodefony/src/token/tokenFilters.js +72 -0
- package/dist/nodefony/src/token/tokenSort.js +40 -0
- package/dist/nodefony/src/token/tokenStatus.js +35 -0
- package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
- package/dist/nodefony/src/totp/totpCipher.js +30 -0
- package/dist/nodefony/src/totp/totpCrypto.js +226 -0
- package/dist/nodefony/src/totp/totpOperations.js +129 -0
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
- package/dist/nodefony/src/voter/RoleVoter.js +32 -0
- package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
- package/dist/nodefony/src/voter/voterRegistry.js +20 -0
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
- package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
- package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
- package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
- package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
- package/dist/nodefony/src/webhook/webhookSort.js +48 -0
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
- package/dist/types/index.d.ts +157 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
- package/dist/types/nodefony/command/security-token.d.ts +44 -0
- package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
- package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +295 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
- package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
- package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
- package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
- package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
- package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
- package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
- package/dist/types/nodefony/contracts/index.d.ts +9 -0
- package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
- package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
- package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
- package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
- package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +8 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
- package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
- package/dist/types/nodefony/service/auditService.d.ts +30 -0
- package/dist/types/nodefony/service/authFlow.d.ts +123 -0
- package/dist/types/nodefony/service/authorization.d.ts +33 -0
- package/dist/types/nodefony/service/cors.d.ts +48 -0
- package/dist/types/nodefony/service/csrf.d.ts +57 -0
- package/dist/types/nodefony/service/firewall.d.ts +148 -0
- package/dist/types/nodefony/service/oauth2.d.ts +66 -0
- package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
- package/dist/types/nodefony/service/tokenService.d.ts +103 -0
- package/dist/types/nodefony/service/totp.d.ts +58 -0
- package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
- package/dist/types/nodefony/service/webhooks.d.ts +160 -0
- package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
- package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
- package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
- package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
- package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
- package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
- package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
- package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
- package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
- package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
- package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
- package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
- package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
- package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
- package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
- package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
- package/dist/types/nodefony/src/csp.d.ts +39 -0
- package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
- package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
- package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
- package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
- package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
- package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
- package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
- package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
- package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
- package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
- package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
- package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
- package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
- package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
- package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
- package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
- package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
- package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
- package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
- package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
- package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
- package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
- package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
- package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
- package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
- package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
- package/docs/api-keys.md +691 -0
- package/docs/audit.md +751 -0
- package/docs/authenticators.md +487 -0
- package/docs/authorization.md +497 -0
- package/docs/cors.md +497 -0
- package/docs/csrf.md +392 -0
- package/docs/external-jwt.md +181 -0
- package/docs/firewall.md +546 -0
- package/docs/headers.md +616 -0
- package/docs/index.md +207 -0
- package/docs/lexique.md +190 -0
- package/docs/oauth2.md +575 -0
- package/docs/obtenir-un-jeton.md +225 -0
- package/docs/tokens.md +520 -0
- package/docs/totp.md +804 -0
- package/docs/webauthn.md +733 -0
- package/docs/webhooks.md +1016 -0
- 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)
|