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