@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/webauthn.md
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "WebAuthn / passkeys — MFA résistant au phishing (FIDO2)"
|
|
3
|
+
navTitle: WebAuthn / passkeys
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/security"
|
|
6
|
+
topic: webauthn
|
|
7
|
+
coverageModule: security
|
|
8
|
+
coverageFiles: "webAuthn.ts,MemoryWebAuthnCredentialStore,webAuthnCredentialStoreRegistry"
|
|
9
|
+
section: "Sécurité"
|
|
10
|
+
audience: [developer]
|
|
11
|
+
tags:
|
|
12
|
+
[
|
|
13
|
+
security,
|
|
14
|
+
webauthn,
|
|
15
|
+
passkeys,
|
|
16
|
+
fido2,
|
|
17
|
+
mfa,
|
|
18
|
+
phishing-resistant,
|
|
19
|
+
ceremony,
|
|
20
|
+
counter,
|
|
21
|
+
credential-store,
|
|
22
|
+
]
|
|
23
|
+
version: "doc"
|
|
24
|
+
status: stable
|
|
25
|
+
updated: 2026-07-19
|
|
26
|
+
source: "src/packages/@nodefony/security/docs/webauthn.md"
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# WebAuthn / passkeys — l'authentification qui ne se phishe pas
|
|
30
|
+
|
|
31
|
+
> Une passkey remplace le mot de passe par une **paire de clés** dont la privée **ne quitte jamais**
|
|
32
|
+
> l'authenticator (Touch ID, Windows Hello, clé FIDO). Le serveur ne détient que des **clés
|
|
33
|
+
> publiques** et ne fait que **vérifier des signatures** : rien à hameçonner, rien à rejouer, rien à
|
|
34
|
+
> voler dans la base. Nodefony orchestre les deux cérémonies FIDO2 dans `WebAuthnService`
|
|
35
|
+
> (`webAuthn.ts:92`) et fournit les endpoints BFF prêts à l'emploi — tu n'écris que l'appel
|
|
36
|
+
> navigateur.
|
|
37
|
+
|
|
38
|
+
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **WebAuthn / Passkeys**
|
|
39
|
+
|
|
40
|
+
## 🧠 Le modèle mental — deux cérémonies, un défi jamais rejoué
|
|
41
|
+
|
|
42
|
+
Une passkey ne se transmet pas : elle **signe un défi**. Le serveur émet un aléa, l'authenticator le
|
|
43
|
+
signe, le serveur vérifie la signature contre la clé publique qu'il a stockée. Deux cérémonies, la
|
|
44
|
+
même mécanique.
|
|
45
|
+
|
|
46
|
+
```mermaid
|
|
47
|
+
flowchart LR
|
|
48
|
+
D["défi émis<br/>+ stocké EN SESSION"] --> S["l'authenticator signe<br/>(la clé privée ne sort pas)"]
|
|
49
|
+
S --> V{"vérification serveur"}
|
|
50
|
+
V -->|"§7.1 enregistrement"| R["plafond maxPerUser<br/>→ clé PUBLIQUE stockée"]
|
|
51
|
+
V -->|"§7.2 authentification"| A["compteur anti-clone<br/>→ session BFF ouverte"]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Le défi vit **hors du service**, en session BFF, posé par le controller
|
|
55
|
+
(`WebAuthnController.registerOptions()`, `WebAuthnController.ts:100`). Chaque `verify*` reçoit
|
|
56
|
+
l'`expectedChallenge` qu'il a émis, et le controller **l'invalide dès sa lecture**
|
|
57
|
+
(`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:277`) : un défi ne sert qu'une fois.
|
|
58
|
+
|
|
59
|
+
## 📖 Lexique
|
|
60
|
+
|
|
61
|
+
| Terme | Sens |
|
|
62
|
+
| ---------------------- | ------------------------------------------------------------------------------------------------ |
|
|
63
|
+
| Passkey | Paire de clés FIDO2 ; la privée vit dans l'authenticator, jamais sur le serveur. |
|
|
64
|
+
| Authenticator | Le matériel qui garde la clé : Touch ID / Windows Hello (`platform`) ou clé USB/NFC, téléphone. |
|
|
65
|
+
| Cérémonie | Séquence normalisée d'un enregistrement (§7.1) ou d'une authentification (§7.2). |
|
|
66
|
+
| RP | _Relying Party_ : ton application, identifiée par un `rpId` (un domaine enregistrable). |
|
|
67
|
+
| `rpId` | Le domaine auquel la passkey est **liée** — la signature ne vaut que pour lui. |
|
|
68
|
+
| Challenge (défi) | Aléa émis par le serveur, signé par l'authenticator — anti-rejeu. |
|
|
69
|
+
| Assertion | La réponse signée d'une authentification (§7.2). |
|
|
70
|
+
| Attestation | La réponse d'un enregistrement (§7.1), éventuellement accompagnée d'un certificat fabricant. |
|
|
71
|
+
| `signCount` | Compteur incrémenté par l'authenticator — une régression trahit un **clone** (§6.1.1). |
|
|
72
|
+
| UV | _User Verification_ : l'authenticator a vérifié l'humain (biométrie/PIN), pas juste sa présence. |
|
|
73
|
+
| BE / BS | _Backup Eligible_ / _Backup State_ : la passkey **peut** être synchronisée / **l'est** (§6.1.3). |
|
|
74
|
+
| Découvrable (resident) | Passkey que l'authenticator sait proposer seul → login **sans saisir d'identifiant**. |
|
|
75
|
+
| `userHandle` | Identifiant opaque du porteur côté authenticator — ici l'identifiant applicatif (username). |
|
|
76
|
+
| COSE | _CBOR Object Signing and Encryption_ (RFC 8152) : le format de la clé publique stockée. |
|
|
77
|
+
| CTAP2 | Le protocole entre le navigateur et un authenticator externe (volet FIDO2 de WebAuthn). |
|
|
78
|
+
| BFF | _Backend-For-Frontend_ : le serveur porte la session et le défi pour le front web. |
|
|
79
|
+
|
|
80
|
+
## Qu'est-ce qu'une passkey — et quelles failles elle ferme
|
|
81
|
+
|
|
82
|
+
Un mot de passe est un **secret partagé** : il se tape, donc il se hameçonne ; il se stocke, donc il
|
|
83
|
+
fuit ; il se rejoue. Une passkey supprime le secret partagé — il n'y a plus rien à donner à un faux
|
|
84
|
+
site.
|
|
85
|
+
|
|
86
|
+
Quatre failles fermées **par construction** :
|
|
87
|
+
|
|
88
|
+
1. **Hameçonnage** — la signature est liée à l'origine et au `rpId`. Un faux domaine ne peut pas
|
|
89
|
+
obtenir de signature valide : le navigateur refuse de la produire.
|
|
90
|
+
2. **Fuite de base** — le serveur ne stocke que des clés **publiques** (`IWebAuthnCredential.publicKey`,
|
|
91
|
+
`IWebAuthnCredential.ts:16`). Une base volée ne donne aucun accès.
|
|
92
|
+
3. **Rejeu** — le défi est à usage unique, invalidé en session dès sa lecture
|
|
93
|
+
(`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:277`).
|
|
94
|
+
4. **Clonage d'authenticator** — le `signCount` doit croître ; une régression signale une copie
|
|
95
|
+
(`IWebAuthnCredential.signCount`, `IWebAuthnCredential.ts:22`).
|
|
96
|
+
|
|
97
|
+
C'est le facteur d'authentification le plus fort disponible aujourd'hui (NIST AAL2, AAL3 avec une clé
|
|
98
|
+
matérielle attestée).
|
|
99
|
+
|
|
100
|
+
## La vision Nodefony — le serveur ne détient aucun secret
|
|
101
|
+
|
|
102
|
+
`WebAuthnService` (`webAuthn.ts:92`) **orchestre**, il ne fait pas de cryptographie : le parsing
|
|
103
|
+
CBOR/COSE et la vérification des signatures (ES256/RS256/EdDSA) sont délégués à
|
|
104
|
+
`@simplewebauthn/server`, une bibliothèque auditée de l'écosystème, **importée paresseusement** au
|
|
105
|
+
premier usage (`WebAuthnService.#ensureLib()`, `webAuthn.ts:540`) — l'enrôlement et le login sont des
|
|
106
|
+
chemins froids, ils ne doivent rien coûter aux requêtes ordinaires.
|
|
107
|
+
|
|
108
|
+
Trois partis pris assumés :
|
|
109
|
+
|
|
110
|
+
- **Le service est sans état de session.** Le défi est porté par le controller BFF ; le service reçoit
|
|
111
|
+
toujours l'`expectedChallenge` en paramètre (`WebAuthnService.verifyRegistration()`,
|
|
112
|
+
`webAuthn.ts:317`). Conséquence pratique : le service se teste sans transport, et un défi n'est
|
|
113
|
+
jamais « oublié » quelque part côté serveur.
|
|
114
|
+
- **Le stockage est pluggable** (`IWebAuthnCredentialStore`, `IWebAuthnCredentialStore.ts:75`) :
|
|
115
|
+
mémoire par défaut, ORM ou Redis en production, avec le **même banc de contrat** pour tous.
|
|
116
|
+
- **Les endpoints sont fournis**, pas à réécrire (`mountWebAuthnRoutes()`,
|
|
117
|
+
`WebAuthnController.ts:308`) : c'est là que vivent les gardes délicates (session du défi, usage
|
|
118
|
+
unique, messages uniformes, anti-IDOR).
|
|
119
|
+
|
|
120
|
+
## 🚀 Démarrage rapide
|
|
121
|
+
|
|
122
|
+
### 1. Les passkeys sont déjà actives — la config utile
|
|
123
|
+
|
|
124
|
+
`passkeys.enabled` vaut `true` par défaut (`config.ts:1106`). Ce que tu déclares vraiment, c'est **ton
|
|
125
|
+
domaine** : sans `rpId`, le service prend le domaine de l'app, et bascule sur `localhost` si c'est une
|
|
126
|
+
adresse IP (un navigateur refuse une IP comme `rpId`, `webAuthn.ts:134`).
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
// nodefony.config.ts (extrait) — activer les passkeys pour TON domaine
|
|
130
|
+
import { defineConfig, use } from "nodefony";
|
|
131
|
+
|
|
132
|
+
export default defineConfig(() => ({
|
|
133
|
+
modules: [
|
|
134
|
+
use("@nodefony/security", {
|
|
135
|
+
passkeys: {
|
|
136
|
+
// Le domaine auquel les passkeys seront LIÉES. Domaine enregistrable
|
|
137
|
+
// ou "localhost" — jamais une IP, jamais un domaine avec port.
|
|
138
|
+
rpId: "app.example.com",
|
|
139
|
+
rpName: "Ma boutique",
|
|
140
|
+
// Liste blanche des origines acceptées (prod) : sans elle, seule
|
|
141
|
+
// l'origine dont le hostname == rpId est tolérée.
|
|
142
|
+
origins: ["https://app.example.com"],
|
|
143
|
+
// Exiger la biométrie/PIN, pas la simple présence → AAL2.
|
|
144
|
+
userVerification: "required",
|
|
145
|
+
// "any" = le navigateur peut aussi proposer un téléphone par QR.
|
|
146
|
+
authenticatorAttachment: "any",
|
|
147
|
+
maxPerUser: 10,
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
"@nodefony/framework",
|
|
151
|
+
],
|
|
152
|
+
}));
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### 2. Les endpoints BFF sont FOURNIS
|
|
156
|
+
|
|
157
|
+
Dès que `@nodefony/security` est chargé, le framework monte six routes
|
|
158
|
+
(`mountWebAuthnRoutes()`, `WebAuthnController.ts:308`) :
|
|
159
|
+
|
|
160
|
+
| Route (`POST` sauf mention) | Rôle | Firewall |
|
|
161
|
+
| -------------------------------------------------- | --------------------------------------------- | ------------------------- |
|
|
162
|
+
| `/nodefony/security/api/webauthn/register/options` | Défi d'enrôlement (session requise) | `bypassFirewall` + `me()` |
|
|
163
|
+
| `/nodefony/security/api/webauthn/register/verify` | Vérifie l'attestation, stocke la clé publique | `bypassFirewall` + `me()` |
|
|
164
|
+
| `/nodefony/security/api/webauthn/login/options` | Défi d'authentification | `bypassFirewall` |
|
|
165
|
+
| `/nodefony/security/api/webauthn/login/verify` | Vérifie l'assertion **et ouvre la session** | `bypassFirewall` |
|
|
166
|
+
| `GET …/webauthn/credentials` | « Mes appareils » du porteur courant | zone protégée |
|
|
167
|
+
| `DELETE …/webauthn/credentials/{id}` | Révoquer **sa** passkey | zone protégée |
|
|
168
|
+
|
|
169
|
+
> [!IMPORTANT]
|
|
170
|
+
> Les quatre routes de cérémonie sont `bypassFirewall: true` (`WebAuthnController.ts:365`) : elles
|
|
171
|
+
> **sont** le mécanisme d'authentification — les protéger exigerait d'être déjà connecté pour se
|
|
172
|
+
> connecter. Le contrôle d'accès de `register/*` est fait **dans le controller**
|
|
173
|
+
> (`flow.me()` → 401, `WebAuthnController.ts:106`). Les deux routes self-service, elles, restent dans
|
|
174
|
+
> la zone protégée (`security.webauthn.credentials.list`, `WebAuthnController.ts:314`).
|
|
175
|
+
|
|
176
|
+
### 3. Ce que TU écris : l'appel navigateur
|
|
177
|
+
|
|
178
|
+
Seule la moitié cliente te revient. `startRegistration()` / `startAuthentication()` de
|
|
179
|
+
`@simplewebauthn/browser` encapsulent `navigator.credentials.create()` / `.get()` et la conversion
|
|
180
|
+
base64url des options JSON.
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
// src/passkeys.ts (navigateur) — l'appel qui déclenche Touch ID / Windows Hello
|
|
184
|
+
import {
|
|
185
|
+
startAuthentication,
|
|
186
|
+
startRegistration,
|
|
187
|
+
type PublicKeyCredentialCreationOptionsJSON,
|
|
188
|
+
type PublicKeyCredentialRequestOptionsJSON,
|
|
189
|
+
} from "@simplewebauthn/browser";
|
|
190
|
+
|
|
191
|
+
const BASE = "/nodefony/security/api/webauthn";
|
|
192
|
+
|
|
193
|
+
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
|
194
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { "content-type": "application/json" },
|
|
197
|
+
// Le cookie de session porte le DÉFI entre `options` et `verify`.
|
|
198
|
+
credentials: "same-origin",
|
|
199
|
+
body: JSON.stringify(body),
|
|
200
|
+
});
|
|
201
|
+
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
202
|
+
return (await res.json()) as T;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Enrôler : l'utilisateur est DÉJÀ connecté (session BFF ouverte). */
|
|
206
|
+
export async function enrollPasskey(): Promise<string> {
|
|
207
|
+
const optionsJSON = await postJson<PublicKeyCredentialCreationOptionsJSON>(
|
|
208
|
+
"/register/options",
|
|
209
|
+
{},
|
|
210
|
+
);
|
|
211
|
+
// La paire est créée DANS l'authenticator ; la privée n'en sort jamais.
|
|
212
|
+
const attestation = await startRegistration({ optionsJSON });
|
|
213
|
+
const out = await postJson<{ verified: boolean; credentialId: string }>(
|
|
214
|
+
"/register/verify",
|
|
215
|
+
{ response: attestation },
|
|
216
|
+
);
|
|
217
|
+
return out.credentialId;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Se connecter sans mot de passe. `username` omis = passkey découvrable. */
|
|
221
|
+
export async function loginWithPasskey(
|
|
222
|
+
username?: string,
|
|
223
|
+
): Promise<{ username: string }> {
|
|
224
|
+
const optionsJSON = await postJson<PublicKeyCredentialRequestOptionsJSON>(
|
|
225
|
+
"/login/options",
|
|
226
|
+
username ? { username } : {},
|
|
227
|
+
);
|
|
228
|
+
const assertion = await startAuthentication({ optionsJSON });
|
|
229
|
+
const out = await postJson<{ verified: boolean; user: { username: string } }>(
|
|
230
|
+
"/login/verify",
|
|
231
|
+
{ response: assertion },
|
|
232
|
+
);
|
|
233
|
+
return out.user; // la session BFF est ouverte : le cookie est posé
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Référence vivante dans le dépôt : Studio fait exactement ces deux appels —
|
|
238
|
+
`AuthService.loginWithPasskey()` (`AuthService.ts:108`) et `AuthService.registerPasskey()`
|
|
239
|
+
(`AuthService.ts:130`).
|
|
240
|
+
|
|
241
|
+
### 4. Ce qu'on observe
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
WA=https://localhost:5152/nodefony/security/api/webauthn
|
|
245
|
+
|
|
246
|
+
# 1) Enrôler sans session → 401 (register exige d'être connecté)
|
|
247
|
+
curl -sk -o /dev/null -w '%{http_code}\n' -X POST $WA/register/options # 401
|
|
248
|
+
|
|
249
|
+
# 2) Défi de login en anonyme → 200 + un cookie de session qui PORTE le défi
|
|
250
|
+
curl -sk -i -X POST -H 'Content-Type: application/json' -d '{}' $WA/login/options
|
|
251
|
+
# HTTP/2 200 … set-cookie: … ; {"challenge":"…","rpId":"localhost","timeout":60000, …}
|
|
252
|
+
|
|
253
|
+
# 3) Rejouer ce cookie sur un verify bidon → 401 (défi trouvé, crypto KO),
|
|
254
|
+
# puis 400 au 2e essai : le défi a été CONSOMMÉ.
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## 🏗️ Architecture interne — les deux cérémonies, pas à pas
|
|
258
|
+
|
|
259
|
+
```mermaid
|
|
260
|
+
sequenceDiagram
|
|
261
|
+
participant N as Navigateur
|
|
262
|
+
participant C as WebAuthnController (BFF)
|
|
263
|
+
participant S as WebAuthnService
|
|
264
|
+
participant St as Store de credentials
|
|
265
|
+
N->>C: POST login/options {}
|
|
266
|
+
C->>C: authFlow.me() — identité de session, jamais la requête
|
|
267
|
+
C->>S: generateAuthenticationOptions(userId?)
|
|
268
|
+
S->>St: findByUser (seulement si session authentifiée)
|
|
269
|
+
S-->>C: options + challenge
|
|
270
|
+
C->>C: session.set(AUTH_CHALLENGE) + save
|
|
271
|
+
C-->>N: options JSON (+ Set-Cookie)
|
|
272
|
+
N->>N: l'authenticator signe (biométrie / PIN)
|
|
273
|
+
N->>C: POST login/verify {response}
|
|
274
|
+
C->>C: takeChallenge → lit PUIS invalide
|
|
275
|
+
C->>S: verifyAuthentication(response, challenge, origin)
|
|
276
|
+
S->>St: findById(credentialId)
|
|
277
|
+
S->>S: signature vs clé publique + origine + rpIdHash + compteur
|
|
278
|
+
S->>St: update(signCount, backupState, uvInitialized, lastUsedAt)
|
|
279
|
+
S-->>C: {userId}
|
|
280
|
+
C->>C: authFlow.establishSessionFor(userId)
|
|
281
|
+
C-->>N: {verified:true, user} + cookie de session
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Enrôler une passkey sur un compte existant
|
|
285
|
+
|
|
286
|
+
`WebAuthnService.generateRegistrationOptions()` (`webAuthn.ts:276`) construit le défi et les
|
|
287
|
+
contraintes. **`excludeCredentials`** y liste les passkeys déjà enrôlées (`webAuthn.ts:291`) : le même
|
|
288
|
+
authenticator ne peut pas s'inscrire deux fois. Dans `authenticatorSelection` (`webAuthn.ts:261`),
|
|
289
|
+
`authenticatorAttachment` n'est transmis **que** s'il vaut autre chose que `"any"` — `"any"` rend la
|
|
290
|
+
main au navigateur, téléphone par QR compris.
|
|
291
|
+
|
|
292
|
+
`WebAuthnService.verifyRegistration()` (`webAuthn.ts:317`) enchaîne dans cet ordre :
|
|
293
|
+
|
|
294
|
+
1. **Vérification déléguée** à `verifyRegistrationResponse` — défi, origine, rpIdHash, flags,
|
|
295
|
+
attestation (`webAuthn.ts:330`). Tout échec devient un message uniforme
|
|
296
|
+
`WebAuthn registration failed` (`webAuthn.ts:305`).
|
|
297
|
+
2. **Plafond d'enrôlement** — `countByUser`, refus `409` si `maxPerUser` est atteint
|
|
298
|
+
(`webAuthn.ts:313`). Volontairement **après** la cryptographie et **avant** le `save` : un client
|
|
299
|
+
peut poster `register/verify` sans jamais appeler `register/options` — c'est l'écriture qu'il faut
|
|
300
|
+
garder, pas la génération du défi.
|
|
301
|
+
3. **Persistance** de la clé publique + l'état initial (`webAuthn.ts:359`) — `backupEligible` dérive
|
|
302
|
+
de `credentialDeviceType === "multiDevice"` (`webAuthn.ts:359`).
|
|
303
|
+
|
|
304
|
+
### Se connecter sans mot de passe
|
|
305
|
+
|
|
306
|
+
`WebAuthnService.generateAuthenticationOptions()` a deux régimes : **sans `userId`**,
|
|
307
|
+
`allowCredentials` est omis et l'authenticator propose ses passkeys découvrables — l'expérience
|
|
308
|
+
« usernameless » ; **avec `userId`**, la liste est calculée depuis `findByUser` pour cibler un
|
|
309
|
+
porteur précis.
|
|
310
|
+
|
|
311
|
+
**C'est le controller qui choisit le régime, et il ne se fie jamais à la requête** :
|
|
312
|
+
`WebAuthnController.loginOptions()` cible depuis l'identité de la **session** quand il y en a une
|
|
313
|
+
(ré-authentification), et sert un défi découvrable sinon. Le `username` que poste un client anonyme
|
|
314
|
+
est ignoré.
|
|
315
|
+
|
|
316
|
+
> [!IMPORTANT]
|
|
317
|
+
> **Un `allowCredentials` peuplé pour un anonyme dit deux choses de trop** : que ce compte porte une
|
|
318
|
+
> passkey, et **lesquelles**. W3C WebAuthn L3 (« Privacy leak via credential IDs ») rappelle qu'un
|
|
319
|
+
> `credentialId` est un identifiant corrélable : exposé, il permet de dés-anonymiser un utilisateur
|
|
320
|
+
> d'un site à l'autre et de confirmer une hypothèse d'identité avec un accès momentané à son
|
|
321
|
+
> authenticator. Les deux remèdes de la spec sont ceux appliqués ici : credentials découvrables, ou
|
|
322
|
+
> authentification préalable. Conséquence de configuration : `passkeys.residentKey: "discouraged"`
|
|
323
|
+
> produit des credentials non découvrables — leurs porteurs ne pourront plus se connecter, et le
|
|
324
|
+
> service l'avertit au boot.
|
|
325
|
+
|
|
326
|
+
`WebAuthnService.verifyAuthentication()` (`webAuthn.ts:415`) résout le credential par son id
|
|
327
|
+
(`webAuthn.ts:415`), vérifie la signature contre la clé publique stockée, puis **applique l'état** :
|
|
328
|
+
`signCount`, `backupState`, `uvInitialized` (jamais rétrogradé), `lastUsedAt` (`webAuthn.ts:455`). Le
|
|
329
|
+
controller ouvre alors la session BFF avec `authFlow.establishSessionFor()`
|
|
330
|
+
(`WebAuthnController.ts:64`).
|
|
331
|
+
|
|
332
|
+
> [!IMPORTANT]
|
|
333
|
+
> **La détection de clone est ici, pas dans le store.** La monotonie du `signCount` est vérifiée
|
|
334
|
+
> pendant la cérémonie ; le store, lui, écrit ce qu'on lui donne — une régression 5→1 y passe sans
|
|
335
|
+
> broncher, et c'est prouvé exprès (cas A3 de `webauthn.attack.test.ts`). Un store n'est pas un
|
|
336
|
+
> arbitre de sécurité.
|
|
337
|
+
|
|
338
|
+
### Perdre son téléphone — révoquer une passkey
|
|
339
|
+
|
|
340
|
+
Deux chemins, deux portées :
|
|
341
|
+
|
|
342
|
+
- **Self-service** : `DELETE …/webauthn/credentials/{id}` → `WebAuthnService.removeUserCredential()`
|
|
343
|
+
(`webAuthn.ts:461`). La suppression n'aboutit que si le credential **appartient** au demandeur
|
|
344
|
+
(`webAuthn.ts:467`) ; sinon **404 indiscernable** (`WebAuthnController.ts:207`) — on ne révèle
|
|
345
|
+
jamais l'existence de la passkey d'autrui.
|
|
346
|
+
- **Reset administrateur** : `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}`
|
|
347
|
+
(`SecurityAdminApi.ts:560`) — audité, et **404 identique** si la passkey n'appartient pas à
|
|
348
|
+
l'utilisateur visé, même pour un admin.
|
|
349
|
+
|
|
350
|
+
Une passkey **non sauvegardée** (`backupState: false`) meurt avec son appareil — d'où le filtre
|
|
351
|
+
`backedUp` du listing admin (`IWebAuthnListQuery`, `IWebAuthnCredentialStore.ts:47`), qui liste
|
|
352
|
+
exactement les porteurs à risque de verrouillage.
|
|
353
|
+
|
|
354
|
+
## ⚙️ Configuration
|
|
355
|
+
|
|
356
|
+
Table dérivée du schéma Zod `passkeysSchema` (`config.ts:447`), monté sous la clé `passkeys`
|
|
357
|
+
(`config.ts:1106`).
|
|
358
|
+
|
|
359
|
+
| Option | Type | Défaut | Effet |
|
|
360
|
+
| ------------------------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------ |
|
|
361
|
+
| `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts:449`) |
|
|
362
|
+
| `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts:455`) |
|
|
363
|
+
| `rpName` | string? | `"Nodefony"` | Nom affiché dans l'invite OS/navigateur (`config.ts:459`) |
|
|
364
|
+
| `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts:463`) |
|
|
365
|
+
| `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts:469`) |
|
|
366
|
+
| `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts:483`) |
|
|
367
|
+
| `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts:481`) |
|
|
368
|
+
| `attestation` | `none` \| `direct` \| `enterprise` | `none` | Conveyance du certificat fabricant (`config.ts:487`) |
|
|
369
|
+
| `timeoutMs` | number (ms) | `60000` | Délai laissé à l'utilisateur pour la cérémonie (`config.ts:493`) |
|
|
370
|
+
| `maxPerUser` | number | `20` | Plafond de passkeys par porteur, `409` au-delà (`config.ts:501`) |
|
|
371
|
+
| `challengeTtlS` | number (s) | `300` | **RÉSERVÉ, non câblé** : le défi suit la session (`config.ts:509`) |
|
|
372
|
+
| `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:514`) |
|
|
373
|
+
|
|
374
|
+
### Mise en situation — trois politiques, trois publics
|
|
375
|
+
|
|
376
|
+
**Situation 1 — grand public, zéro friction (le défaut).** Tes utilisateurs sont sur leur téléphone ou
|
|
377
|
+
leur portable : une empreinte suffit, et la passkey se synchronise (iCloud, Google) pour qu'un
|
|
378
|
+
changement d'appareil ne les enferme pas dehors. Rien à écrire, ce sont les défauts — l'OS propose
|
|
379
|
+
Touch ID / Windows Hello sans QR (`authenticatorAttachment: "platform"`) et les passkeys arrivent
|
|
380
|
+
avec `backupEligible: true`.
|
|
381
|
+
|
|
382
|
+
**Situation 2 — comptes sensibles, clé matérielle imposée.** Administrateurs, production : tu veux une
|
|
383
|
+
clé physique séparée et la certitude d'une vérification humaine.
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
passkeys: {
|
|
387
|
+
authenticatorAttachment: "cross-platform", // YubiKey & co, pas la biométrie du portable
|
|
388
|
+
userVerification: "required", // PIN/biométrie obligatoire → AAL2
|
|
389
|
+
attestation: "direct", // demande le certificat fabricant
|
|
390
|
+
maxPerUser: 5,
|
|
391
|
+
},
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
> [!WARNING]
|
|
395
|
+
> `attestation: "direct"` **récupère** le certificat, il ne le **valide pas** : Nodefony ne vérifie ni
|
|
396
|
+
> l'AAGUID ni la chaîne contre la MDS FIDO (`config.ts:487`). Tant que cette vérification n'est pas
|
|
397
|
+
> faite dans ton application, tu as la donnée, pas la garantie AAL3 — et tu paies un coût de vie
|
|
398
|
+
> privée (l'attestation identifie le modèle d'authenticator).
|
|
399
|
+
|
|
400
|
+
**Situation 3 — login sans identifiant (usernameless).** Tu veux un bouton « Se connecter » unique,
|
|
401
|
+
sans champ à remplir.
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
passkeys: { residentKey: "required" }, // la passkey DOIT être découvrable
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Et côté client, **n'envoie pas** `username` à `login/options` : `allowCredentials` est alors omis et
|
|
408
|
+
le navigateur propose les comptes qu'il connaît. C'est aussi la variante la plus sobre côté vie
|
|
409
|
+
privée — voir la section suivante.
|
|
410
|
+
|
|
411
|
+
| Ce que le client envoie à `login/options` | Ce que renvoie le serveur | Conséquence |
|
|
412
|
+
| ----------------------------------------- | ----------------------------------------------- | ------------------------------------------------ |
|
|
413
|
+
| `{}` | défi seul, **sans** `allowCredentials` | l'authenticator propose ses comptes |
|
|
414
|
+
| `{"username":"alice"}` (alice a 3 clés) | défi + `allowCredentials` de **3** identifiants | ciblage — mais l'anonyme apprend qu'ils existent |
|
|
415
|
+
| `{"username":"fantome"}` | défi + `allowCredentials: []` (200, jamais 404) | pas d'erreur révélatrice — mais liste vide |
|
|
416
|
+
|
|
417
|
+
## 🛡️ `rpId` et origines — l'ancre anti-phishing
|
|
418
|
+
|
|
419
|
+
Le `rpId` est ce à quoi la passkey est **soudée**. Le navigateur refuse de signer pour un autre
|
|
420
|
+
domaine : c'est ce lien, et pas une vérification côté serveur, qui rend l'hameçonnage impossible.
|
|
421
|
+
|
|
422
|
+
Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts:113`) : `passkeys.rpId` sinon le domaine
|
|
423
|
+
de l'app ; une **adresse IP ou une adresse IPv6 bascule sur `localhost`** (`webAuthn.ts:134`), seul
|
|
424
|
+
host non-domaine que la spécification autorise. En développement, accède donc au serveur par
|
|
425
|
+
`https://localhost:5152`, jamais par `127.0.0.1`.
|
|
426
|
+
|
|
427
|
+
L'origine attendue est calculée par `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:523`) en trois
|
|
428
|
+
temps : la **liste blanche `passkeys.origins`** si elle est non vide (`webAuthn.ts:484`, la voie de
|
|
429
|
+
production) ; sinon **l'origine de la requête, mais seulement si son hostname est exactement le
|
|
430
|
+
`rpId`** (`webAuthn.ts:134` — en dev, `localhost:5173` et `localhost:5152` passent tous deux, le port
|
|
431
|
+
est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https://{rpId}`
|
|
432
|
+
(`webAuthn.ts:537`).
|
|
433
|
+
|
|
434
|
+
> [!WARNING]
|
|
435
|
+
> **Un seul `rpId` par instance.** Il est résolu une fois au boot et stocké dans le service ; il n'y a
|
|
436
|
+
> **aucune** résolution par en-tête `Host`. Un déploiement multi-domaine (`a.example.com` et
|
|
437
|
+
> `b.example.com`) doit choisir un `rpId` parent commun (`example.com`) — sinon les passkeys enrôlées
|
|
438
|
+
> sur l'un ne fonctionneront pas sur l'autre.
|
|
439
|
+
|
|
440
|
+
## 🔐 Le point à ne pas rater — plafond d'enrôlement et `login/options` ouvert
|
|
441
|
+
|
|
442
|
+
`login/options` est **accessible à un anonyme** (`bypassFirewall`) et accepte un `username`
|
|
443
|
+
(`WebAuthnController.ts:146`). C'est nécessaire — on ne peut pas exiger d'être connecté pour se
|
|
444
|
+
connecter — mais cela ouvre deux surfaces qu'il faut regarder en face.
|
|
445
|
+
|
|
446
|
+
### Amplification : bornée par le plafond, pas par une pagination
|
|
447
|
+
|
|
448
|
+
Avec un `username`, le serveur charge **toutes** les passkeys du porteur pour construire
|
|
449
|
+
`allowCredentials` (`webAuthn.ts:394`). Cet appel `findByUser` est **volontairement non paginé** —
|
|
450
|
+
`allowCredentials` doit être complet ou il est faux : un authenticator absent de la liste ne peut pas
|
|
451
|
+
répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts:88`).
|
|
452
|
+
|
|
453
|
+
Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `config.ts:509`) :
|
|
454
|
+
|
|
455
|
+
- le refus est un `409` porté par `WebAuthnError` (`WebAuthnError.ts:15`), rendu **tel quel** au
|
|
456
|
+
client parce qu'il est authentifié — rien à énumérer, et il doit comprendre qu'il faut retirer un
|
|
457
|
+
appareil (`WebAuthnController.ts:264`) ;
|
|
458
|
+
- le comptage est **natif** (`countByUser`, `IWebAuthnCredentialStore.ts:97`) : `COUNT` / `SCARD`,
|
|
459
|
+
jamais un `findByUser().length` — `webAuthnEnrollmentLimit.test.ts` le prouve avec un store espion
|
|
460
|
+
(3 comptages, 0 chargement) ;
|
|
461
|
+
- retirer un appareil **libère une place** : le plafond est une borne, pas un compteur qui dérive.
|
|
462
|
+
|
|
463
|
+
### Énumération : le statut est uniforme, la liste ne l'est pas
|
|
464
|
+
|
|
465
|
+
Un compte inexistant reçoit **200 + un défi**, exactement comme un compte réel — le test d'attaque E3
|
|
466
|
+
(`webauthn-attack.test.ts`) verrouille ce point. Aucun 404, aucun message différencié, aucune latence
|
|
467
|
+
de recherche de mot de passe.
|
|
468
|
+
|
|
469
|
+
Mais `allowCredentials` reflète la réalité : **vide** pour un identifiant sans passkey, **peuplé** (et
|
|
470
|
+
portant les identifiants de credentials) pour un porteur enrôlé. Un anonyme peut donc distinguer
|
|
471
|
+
« cet identifiant a au moins une passkey » de « il n'en a pas ».
|
|
472
|
+
|
|
473
|
+
> [!WARNING]
|
|
474
|
+
> C'est le comportement standard d'un `login/options` avec identifiant, et la parade est
|
|
475
|
+
> architecturale : **ne transmets pas `username`**. Avec `residentKey: "required"` et un client qui
|
|
476
|
+
> poste `{}`, `allowCredentials` est omis, aucun état de compte ne transparaît, et l'appel ne touche
|
|
477
|
+
> même pas le store. Si ton UX exige la saisie d'un identifiant, place un rate-limit devant
|
|
478
|
+
> `login/options` — le firewall ne le protège pas, c'est une route en `bypassFirewall`.
|
|
479
|
+
|
|
480
|
+
Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
|
|
481
|
+
|
|
482
|
+
| Vecteur | Garde |
|
|
483
|
+
| ----------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
484
|
+
| Rejeu d'un défi | Invalidé à la lecture, avant la crypto (`WebAuthnController.ts:251`) — E1/E1bis |
|
|
485
|
+
| Défi d'enrôlement réutilisé pour un login | Clés de session **disjointes** (`WebAuthnController.ts:68`) — E2 |
|
|
486
|
+
| Lecture de la cause d'échec | Message uniforme `WebAuthn verification failed` (`WebAuthnController.ts:262`) — E4 |
|
|
487
|
+
| Suppression de la passkey d'autrui | Ownership vérifié → 404 indiscernable (`webAuthn.ts:467`) |
|
|
488
|
+
| Réassignation du porteur via `update` | Le patch ne porte que l'état mutable (`WebAuthnAuthUpdate`, `IWebAuthnCredentialStore.ts:58`) — A1 |
|
|
489
|
+
|
|
490
|
+
## Entité de persistance — ce qui est écrit, et en quels types
|
|
491
|
+
|
|
492
|
+
Un credential (`IWebAuthnCredential`, `IWebAuthnCredential.ts:10`) ne contient **aucun secret** : la
|
|
493
|
+
clé privée n'existe que dans l'authenticator.
|
|
494
|
+
|
|
495
|
+
| Champ | Sens | SQL (`colKit`) | Mongoose | Redis (HASH) |
|
|
496
|
+
| ---------------- | ---------------------------------------------------- | ------------------- | ---------------- | ----------------------- |
|
|
497
|
+
| `id` | Identifiant du credential, base64url — clé naturelle | `text` PK | `_id: String` | clé `nf:wac:cred:<id>` |
|
|
498
|
+
| `userId` | Porteur (= identifiant applicatif / `userHandle`) | `text` notNull, idx | `String` indexé | champ + SET `user:<id>` |
|
|
499
|
+
| `publicKey` | Clé publique **COSE**, base64url | `text` notNull | `String` requis | champ |
|
|
500
|
+
| `signCount` | Compteur anti-clone (§6.1.1) | `int` notNull | `Number` requis | champ |
|
|
501
|
+
| `transports` | `usb`\|`nfc`\|`ble`\|`internal`\|`hybrid` | `json` notNull | `[String]` | champ JSON |
|
|
502
|
+
| `backupEligible` | BE flag — fixé à l'enrôlement, **immuable** | `bool` notNull | `Boolean` requis | `"1"`/`"0"` |
|
|
503
|
+
| `backupState` | BS flag — la passkey **est** sauvegardée | `bool` notNull | `Boolean` requis | `"1"`/`"0"` |
|
|
504
|
+
| `uvInitialized` | Une vérification humaine a déjà eu lieu | `bool` notNull | `Boolean` requis | `"1"`/`"0"` |
|
|
505
|
+
| `nickname` | Surnom d'appareil, optionnel | `text` nullable | `String` (null) | champ absent si vide |
|
|
506
|
+
| `createdAt` | Enrôlement (epoch ms) | `epochMs` notNull | `Number` requis | champ |
|
|
507
|
+
| `lastUsedAt` | Dernière authentification réussie, ou `null` | `epochMs` nullable | `Number` (null) | champ absent si null |
|
|
508
|
+
|
|
509
|
+
Spécification SQL : `webAuthnCredentialEntity.ts:28`, index sur `userId`
|
|
510
|
+
(`drizzle/nodefony/entity/webAuthnCredentialEntity.ts:54`). Schéma documentaire :
|
|
511
|
+
`mongoose/nodefony/entity/webAuthnCredentialEntity.ts:25` — `_id` **est** le credentialId (String, pas
|
|
512
|
+
un ObjectId), les horodatages sont des `Number` epoch ms.
|
|
513
|
+
|
|
514
|
+
Trois constats de conception : **aucun TTL ni `gc`** — une passkey est permanente jusqu'à révocation
|
|
515
|
+
explicite, `IWebAuthnCredentialStore` n'a pas de maintenance (`IWebAuthnCredentialStore.ts:75`) ;
|
|
516
|
+
**`nickname` est
|
|
517
|
+
stocké et rendu, jamais écrit par le framework** — aucune API publique ne le renseigne, le champ
|
|
518
|
+
existe pour une UX « renommer cet appareil » côté application ; **`userId` est l'identifiant
|
|
519
|
+
applicatif** (`me.username`, figé à l'enrôlement, `WebAuthnController.ts:109`) — renommer un compte
|
|
520
|
+
orphelinise donc ses passkeys.
|
|
521
|
+
|
|
522
|
+
## 🧩 Backends de stockage — quatre enregistrés, et comment brancher le tien
|
|
523
|
+
|
|
524
|
+
Le contrat `IWebAuthnCredentialStore` (`IWebAuthnCredentialStore.ts:75`) est résolu au boot
|
|
525
|
+
(`webAuthn.ts:141`) : un adapter déjà posé au container gagne, sinon la fabrique nommée par
|
|
526
|
+
`passkeys.store` est appelée via le registre (`registerWebAuthnStore()`,
|
|
527
|
+
`webAuthnCredentialStoreRegistry.ts:30`).
|
|
528
|
+
|
|
529
|
+
| Ta situation | Store | Ce que tu gagnes / perds |
|
|
530
|
+
| ------------------------------------------- | ------------------ | ---------------------------------------------------------------------------- |
|
|
531
|
+
| Dev, tests, mono-process | `memory` (builtin) | 0 dépendance — **volatil** : toutes les passkeys perdues au redémarrage |
|
|
532
|
+
| Prod, base SQL déclarée (`NF_DATABASE_URL`) | `auto` → `drizzle` | durable + partagé entre pods ; pagination offset + `total` exact |
|
|
533
|
+
| Prod, MongoDB | `mongoose` | même contrat, même banc, sur Mongo |
|
|
534
|
+
| Flotte de pods, Redis déjà présent | `redis` | O(1) par credential ; listing **par curseur**, sans total (capacité réduite) |
|
|
535
|
+
|
|
536
|
+
**`store: "auto"` (défaut)** suit l'infra déclarée, borné aux backends réellement enregistrés
|
|
537
|
+
(`webAuthn.ts:155`) — la décision (configuré → résolu, raison) est publiée au kernel et visible dans
|
|
538
|
+
Studio (`webAuthn.ts:195`). Deux garde-fous de production :
|
|
539
|
+
|
|
540
|
+
- un store **explicitement** configuré mais inconnu **avorte le boot en production**
|
|
541
|
+
(`webAuthn.ts:176`) — jamais de repli silencieux vers un store volatil ;
|
|
542
|
+
- `memory` **en production** déclenche un `WARNING` qui nomme l'impact : passkeys perdues au
|
|
543
|
+
redémarrage, utilisateurs verrouillés hors de leur compte (`webAuthn.ts:183`).
|
|
544
|
+
|
|
545
|
+
### Les backends, en détail
|
|
546
|
+
|
|
547
|
+
### `memory` — la référence, 0 dépendance
|
|
548
|
+
|
|
549
|
+
- Builtin, enregistré à l'import du module (`webAuthnCredentialStoreRegistry.ts:53`). Deux index :
|
|
550
|
+
`#byId` (vérité) et `#idsByUser` (`allowCredentials`) — `MemoryWebAuthnCredentialStore.ts:47`.
|
|
551
|
+
- `listPage` trie `createdAt` DESC avec `id` en départage → offset déterministe, parité SQL
|
|
552
|
+
(`MemoryWebAuthnCredentialStore.ts:131`). C'est lui qui pilote le banc de contrat partagé.
|
|
553
|
+
- `snapshot()` / `restore()` sérialisables (`MemoryWebAuthnCredentialStore.ts:153`) ; le service
|
|
554
|
+
déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts:254`).
|
|
555
|
+
|
|
556
|
+
### `drizzle` — SQL, le durable par défaut
|
|
557
|
+
|
|
558
|
+
- Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:262`) ;
|
|
559
|
+
`DrizzleWebAuthnCredentialStore` (`DrizzleWebAuthnCredentialStore.ts:37`) est **100 % portable** —
|
|
560
|
+
aucune requête SQL native, tout passe par `IRepository` d'`orm-core`.
|
|
561
|
+
- Trois dialectes sur le même banc : **sqlite** (toujours, `:memory:`), **postgres** et **mysql**
|
|
562
|
+
(gatés par l'infra). Pagination offset + `total` (`DrizzleWebAuthnCredentialStore.ts:188`).
|
|
563
|
+
|
|
564
|
+
### `mongoose` — MongoDB
|
|
565
|
+
|
|
566
|
+
- Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts:139`) ;
|
|
567
|
+
`MongooseWebAuthnCredentialStore` (`MongooseWebAuthnCredentialStore.ts:36`) partage le helper
|
|
568
|
+
`paginate()` — offset + `total`, départage sur `_id` (`MongooseWebAuthnCredentialStore.ts:158`).
|
|
569
|
+
|
|
570
|
+
### `redis` — cluster, lecture O(1)
|
|
571
|
+
|
|
572
|
+
- Enregistré par le module redis (`redis/nodefony/registerStores.ts:62`) ;
|
|
573
|
+
`RedisWebAuthnCredentialStore` (`RedisWebAuthnCredentialStore.ts:93`) stocke un **HASH** par
|
|
574
|
+
credential + un **SET** d'ids par porteur — `update` réécrit 1 à 4 champs sans relire
|
|
575
|
+
l'enregistrement (`RedisWebAuthnCredentialStore.ts:219`).
|
|
576
|
+
- Listing par `SCAN`, curseur composite `skip:scanCursor` (`RedisWebAuthnCredentialStore.ts:257`) :
|
|
577
|
+
**ni ordre global ni total**, pages de taille variable — capacité réduite **déclarée**, pas un
|
|
578
|
+
défaut. `countCredentials()` renvoie `-1` (`RedisWebAuthnCredentialStore.ts:330`).
|
|
579
|
+
|
|
580
|
+
### Brancher son propre store
|
|
581
|
+
|
|
582
|
+
Une fabrique suffit — le cœur n'apprend jamais le nom d'un backend :
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
import { registerWebAuthnStore } from "@nodefony/security";
|
|
586
|
+
import type { IWebAuthnCredentialStore } from "@nodefony/security";
|
|
587
|
+
|
|
588
|
+
registerWebAuthnStore("mon-backend", ({ container, config }) => {
|
|
589
|
+
// Implémenter IWebAuthnCredentialStore : findById / findByUser / countByUser /
|
|
590
|
+
// save / update / delete / listPage / countCredentials.
|
|
591
|
+
return new MyWebAuthnStore(container, config) as IWebAuthnCredentialStore;
|
|
592
|
+
});
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
Puis `passkeys: { store: "mon-backend" }`. Ton implémentation doit passer le **banc de contrat**
|
|
596
|
+
(`webauthnPaginationContract.ts`) : il vérifie la borne `limit`, l'ordre, les filtres, et surtout que
|
|
597
|
+
la vue admin **ne porte jamais la clé publique**.
|
|
598
|
+
|
|
599
|
+
## 🧰 API publique
|
|
600
|
+
|
|
601
|
+
Le point d'entrée est le service `webauthn` du container ; les signatures vivent dans le graphe
|
|
602
|
+
symbolique (`.ai/symbols.json`).
|
|
603
|
+
|
|
604
|
+
| Méthode | Quand tu l'appelles |
|
|
605
|
+
| --------------------------------- | ------------------------------------------------------------------------ |
|
|
606
|
+
| `isEnabled()` | Savoir si les cérémonies sont opérationnelles (`webAuthn.ts:262`) |
|
|
607
|
+
| `generateRegistrationOptions()` | Défi d'enrôlement pour un porteur (`webAuthn.ts:276`) |
|
|
608
|
+
| `verifyRegistration()` | Vérifier + stocker, plafond appliqué (`webAuthn.ts:317`) |
|
|
609
|
+
| `generateAuthenticationOptions()` | Défi de login, ciblé ou découvrable (`webAuthn.ts:384`) |
|
|
610
|
+
| `verifyAuthentication()` | Vérifier l'assertion + appliquer l'état (`webAuthn.ts:415`) |
|
|
611
|
+
| `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:461`) |
|
|
612
|
+
| `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:474`) |
|
|
613
|
+
| `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts:485`) |
|
|
614
|
+
| `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:502`) |
|
|
615
|
+
| `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:491`) |
|
|
616
|
+
|
|
617
|
+
Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
|
|
618
|
+
`IWebAuthnCredentialStore`, `IWebAuthnCredentialSummary`, `IWebAuthnListQuery`,
|
|
619
|
+
`MemoryWebAuthnCredentialStore`, `registerWebAuthnStore`.
|
|
620
|
+
|
|
621
|
+
> [!TIP]
|
|
622
|
+
> `listUserCredentials()` et `listCredentialsPage()` ne se remplacent pas. Le premier sert la fiche
|
|
623
|
+
> d'**un** porteur (borné par `maxPerUser`) et le login ; le second est le chemin **froid** d'une
|
|
624
|
+
> console d'administration, qui ne matérialise jamais plus d'une page et dont la projection exclut la
|
|
625
|
+
> clé publique par construction (`IWebAuthnCredentialSummary`, `IWebAuthnCredentialStore.ts:16`).
|
|
626
|
+
|
|
627
|
+
## 📡 Observabilité — Studio
|
|
628
|
+
|
|
629
|
+
Le data plane admin du module expose trois routes (`SecurityAdminApi.ts:301`), toutes en
|
|
630
|
+
`ROLE_NODEFONY_ADMIN` :
|
|
631
|
+
|
|
632
|
+
<!-- prettier-ignore -->
|
|
633
|
+
| Route | Ce qu'elle montre |
|
|
634
|
+
| --- | --- |
|
|
635
|
+
| `GET /nodefony/security/api/webauthn/list` | Vue **transverse** paginée : quels appareils portent des passkeys, lesquelles meurent avec leur appareil (`SecurityAdminApi.ts:473`) |
|
|
636
|
+
| `GET /nodefony/security/api/users/{id}/passkeys` | Les passkeys d'un porteur (`SecurityAdminApi.ts:528`) |
|
|
637
|
+
| `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}` | Reset administrateur, audité (`SecurityAdminApi.ts:560`) |
|
|
638
|
+
|
|
639
|
+
Deux comportements à connaître : la **redaction est par construction** — la vue admin omet la clé
|
|
640
|
+
publique et le `userId` déjà présent dans le chemin (`toCredentialView()`, `SecurityAdminApi.ts:267`),
|
|
641
|
+
et ce n'est pas un masquage tardif, le contrat de store ne la produit jamais ; la **lecture est
|
|
642
|
+
défensive** — passkeys désactivées → `{ enabled: false, items: [] }` et non une erreur, la console
|
|
643
|
+
doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts:501`). `total: -1` signale un
|
|
644
|
+
backend sans comptage (Redis).
|
|
645
|
+
|
|
646
|
+
Côté UI, l'écran **Profil** (`/nodefony/profile`) de Studio porte l'enrôlement self-service et l'écran de login le bouton
|
|
647
|
+
passkey (`AuthStore.ts:209`).
|
|
648
|
+
|
|
649
|
+
## 📜 Normes appliquées
|
|
650
|
+
|
|
651
|
+
| Domaine | Norme | Ancrage |
|
|
652
|
+
| ----------------------------------- | ---------------------------------- | ------------------------------------------------------------------ |
|
|
653
|
+
| Cérémonie d'enregistrement | W3C WebAuthn L3 §7.1 | `WebAuthnService.verifyRegistration()` (`webAuthn.ts:317`) |
|
|
654
|
+
| Cérémonie d'authentification | W3C WebAuthn L3 §7.2 | `WebAuthnService.verifyAuthentication()` (`webAuthn.ts:415`) |
|
|
655
|
+
| Compteur anti-clone | W3C WebAuthn §6.1.1 | `IWebAuthnCredential.signCount` (`IWebAuthnCredential.ts:22`) |
|
|
656
|
+
| Flags de sauvegarde (BE/BS) | W3C WebAuthn §6.1.3 | `IWebAuthnCredential.backupEligible` (`IWebAuthnCredential.ts:29`) |
|
|
657
|
+
| Liaison à l'origine (anti-phishing) | W3C WebAuthn §13.4.8 | `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:523`) |
|
|
658
|
+
| Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts:16`) |
|
|
659
|
+
| FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts:509`) |
|
|
660
|
+
| Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts:449`) |
|
|
661
|
+
| Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts:502`) |
|
|
662
|
+
|
|
663
|
+
La conformité cryptographique fine (parsing CBOR, formats d'attestation, vérification des signatures
|
|
664
|
+
ES256/RS256/EdDSA) est portée par `@simplewebauthn/server` — Nodefony fournit et prouve les
|
|
665
|
+
**invariants qui l'entourent**.
|
|
666
|
+
|
|
667
|
+
## ⚡ Performance & mémoire
|
|
668
|
+
|
|
669
|
+
Les cérémonies sont un **chemin froid** : quelques appels par utilisateur et par appareil, jamais par
|
|
670
|
+
requête. Le code en tire trois conséquences.
|
|
671
|
+
|
|
672
|
+
- **Import paresseux de la bibliothèque** : `@simplewebauthn/server` n'est chargé qu'au premier usage
|
|
673
|
+
réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:540`) — une app qui n'enrôle personne ne paie
|
|
674
|
+
jamais son coût de parse.
|
|
675
|
+
- **Rien d'alloué quand c'est désactivé** : `passkeys.enabled: false` sort de `#build()` immédiatement
|
|
676
|
+
(`webAuthn.ts:122`) — pas de store, pas de `Map`.
|
|
677
|
+
- **Le listing admin ne matérialise jamais plus d'une page** : `listPage` applique les filtres au
|
|
678
|
+
store (`IWebAuthnCredentialStore.ts:114`). Le seul appel non paginé, `findByUser`, est borné par
|
|
679
|
+
`maxPerUser` — par conception (`IWebAuthnCredentialStore.ts:88`).
|
|
680
|
+
|
|
681
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
682
|
+
|
|
683
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
684
|
+
| ---------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
685
|
+
| `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:313`) | Retirer un appareil, ou relever `maxPerUser` |
|
|
686
|
+
| `400 No challenge` au `verify` | Défi absent : déjà consommé, ou pas de cookie renvoyé | Un défi = un `verify` ; envoyer le cookie (`credentials`) |
|
|
687
|
+
| `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi | Renseigner `passkeys.origins` + `rpId` enregistrable |
|
|
688
|
+
| Passkey KO en dev sur `127.0.0.1` | Une IP n'est pas un `rpId` valide → repli `localhost` | Accéder par `https://localhost:<port>` |
|
|
689
|
+
| Passkeys d'un sous-domaine inutilisables sur l'autre | `rpId` unique, résolu au boot, pas de résolution par `Host` | Choisir un `rpId` parent commun (`example.com`) |
|
|
690
|
+
| Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1) | Comportement voulu — ré-enrôler l'appareil |
|
|
691
|
+
| Tout le monde verrouillé dehors après un déploiement | `store` resté en `memory` : credentials volatils (`webAuthn.ts:183`) | Déclarer une infra durable ; le `WARNING` boot le disait |
|
|
692
|
+
| `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué | Activer `passkeys` ; vérifier le store configuré |
|
|
693
|
+
| Un anonyme distingue les comptes à passkey | `allowCredentials` peuplé vs vide sur `login/options` | Ne pas envoyer `username` (usernameless) ; rate-limit la route |
|
|
694
|
+
| `total` absent du listing admin | Backend curseur (Redis) → `countCredentials()` rend `-1` | Paginer par `nextCursor`, ne pas afficher de total |
|
|
695
|
+
| Passkeys orphelines après renommage d'un compte | `userId` = `me.username` figé à l'enrôlement (`WebAuthnController.ts:109`) | Ne pas renommer, ou ré-enrôler après renommage |
|
|
696
|
+
|
|
697
|
+
## 🧪 Tests & couverture
|
|
698
|
+
|
|
699
|
+
Cinq familles couvrent la brique — les **chiffres exacts vivent dans la carte de l'aperçu**
|
|
700
|
+
(régénérée par `gen-counters.mjs`, jamais figée ici) :
|
|
701
|
+
|
|
702
|
+
- **unit** — `webAuthnCredentialStore` (matrice fonctionnelle), `webAuthnCredentialOwnership`
|
|
703
|
+
(anti-IDOR), `webAuthnEnrollmentLimit` (le plafond : refus, tenue au-delà du seuil, portée par
|
|
704
|
+
utilisateur, libération d'une place, comptage natif), `webauthnPagination` ;
|
|
705
|
+
- **intégration** — `webauthn-bff` (serveur réel : Zero Trust sur `register`, persistance du défi,
|
|
706
|
+
`authenticatorAttachment` renvoyé) + les bancs de store `drizzle` / `mongoose` / `redis` ;
|
|
707
|
+
- **e2e (base réelle)** — `webauthn-store-postgres.e2e` et `webauthn-store-mysql.e2e`, gatés par
|
|
708
|
+
l'infra : sans variables de base ils se **skippent**, et un skip compte comme vert ;
|
|
709
|
+
- **bancs de contrat** — `webauthnPaginationContract` : les invariants du listing tenus par **tous**
|
|
710
|
+
les backends, en deux capacités (`offset` et `cursor`), dont la projection sans clé publique ;
|
|
711
|
+
- **attaque** — `webauthn.attack` au niveau store (A1 anti-takeover par `update`, A2 isolation
|
|
712
|
+
`findByUser`, A3 le store n'arbitre pas l'anti-clone, A4 collision d'index) et `webauthn-attack` au
|
|
713
|
+
niveau câblage (E1/E1bis rejeu de défi, E2 confusion de cérémonie, E3 anti-énumération, E4 message
|
|
714
|
+
uniforme).
|
|
715
|
+
|
|
716
|
+
**Ce qui manque** : aucun test de **charge** ni de mémoire dédié — cohérent avec un chemin froid, mais
|
|
717
|
+
un pic d'enrôlement n'est pas caractérisé. La cryptographie n'est pas re-testée ici : elle est
|
|
718
|
+
déléguée à une bibliothèque auditée, et les tests **doublent** volontairement cette dépendance pour
|
|
719
|
+
n'éprouver que la politique Nodefony.
|
|
720
|
+
|
|
721
|
+
Skills utiles : `nodefony-security-review` (mode red/blue-team), `nodefony-load-test` pour
|
|
722
|
+
caractériser un pic d'enrôlement. Couverture : `npm run coverage` dans `@nodefony/security`.
|
|
723
|
+
|
|
724
|
+
## 🔗 Pour aller plus loin
|
|
725
|
+
|
|
726
|
+
- ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
727
|
+
- 🧭 **Pages sœurs** : [Authenticators](authenticators.md) · [totp](totp.md)
|
|
728
|
+
|
|
729
|
+
- Autre second facteur → [totp](./totp.md) · Mot de passe et autres preuves → [authenticators](./authenticators.md)
|
|
730
|
+
- Le firewall qui protège tes routes une fois la session ouverte → [firewall](./firewall.md)
|
|
731
|
+
- La session BFF qui porte le défi → [session](../../http/docs/session.md)
|
|
732
|
+
- Identité fédérée (Google, GitHub…) → [oauth2](./oauth2.md)
|
|
733
|
+
- Vue d'ensemble du module → [index](./index.md)
|