@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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region nodefony/src/token/tokenCriteria.ts
|
|
2
|
+
/**
|
|
3
|
+
* Fragment de critère **portable** exprimant l'état de vie d'un jeton — la
|
|
4
|
+
* traduction que les adapters SQL et Mongo partagent au lieu de la réécrire.
|
|
5
|
+
*
|
|
6
|
+
* Elle tient dans le `Criteria` d'orm-core depuis que celui-ci porte `$or` :
|
|
7
|
+
* « utilisable » signifie *sans échéance* **ou** *échéance à venir*, ce qu'aucune
|
|
8
|
+
* conjonction ne dit. Avant ça, chaque backend serait descendu à son SQL natif —
|
|
9
|
+
* trois écritures de la même règle, et la divergence pour seule perspective.
|
|
10
|
+
*
|
|
11
|
+
* Révoqué l'emporte sur expiré : les deux autres branches exigent donc
|
|
12
|
+
* explicitement `revokedAt IS NULL`. Sans cette précision, une clé révoquée
|
|
13
|
+
* **puis** échue compterait dans deux facettes, et la somme dépasserait le total.
|
|
14
|
+
*
|
|
15
|
+
* @param status - l'état demandé, ou `undefined` pour ne pas filtrer.
|
|
16
|
+
* @param now - instant de référence (injecté : un compteur ne doit pas dépendre
|
|
17
|
+
* du moment où le test tourne).
|
|
18
|
+
* @returns le fragment à fusionner dans le critère, vide si aucun filtre.
|
|
19
|
+
*/
|
|
20
|
+
function tokenStatusCriteria(status, now) {
|
|
21
|
+
switch (status) {
|
|
22
|
+
case "revoked": return { revokedAt: { $null: false } };
|
|
23
|
+
case "expired": return {
|
|
24
|
+
revokedAt: { $null: true },
|
|
25
|
+
expiresAt: { $lte: now }
|
|
26
|
+
};
|
|
27
|
+
case "active": return {
|
|
28
|
+
revokedAt: { $null: true },
|
|
29
|
+
$or: [{ expiresAt: { $null: true } }, { expiresAt: { $gt: now } }]
|
|
30
|
+
};
|
|
31
|
+
default: return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
export { tokenStatusCriteria };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
//#region nodefony/src/token/tokenFilters.ts
|
|
2
|
+
/**
|
|
3
|
+
* **Le vocabulaire de filtre des jetons**, en noms PUBLICS — ceux qu'un client
|
|
4
|
+
* écrit dans l'URL (`?status=revoked`), jamais des noms de colonne.
|
|
5
|
+
*
|
|
6
|
+
* Frère de `TOKEN_SORTABLE_FIELDS`, et posé pour la même raison : le vocabulaire
|
|
7
|
+
* appartient au propriétaire du contrat (`@nodefony/security`), la mécanique de
|
|
8
|
+
* lecture au cœur (`parseFilters`).
|
|
9
|
+
*
|
|
10
|
+
* **La différence avec le tri est structurelle.** Un tri est une CAPACITÉ de
|
|
11
|
+
* backend — Redis ne sait pas trier, donc `sortableFields` se déclare par store.
|
|
12
|
+
* Un filtre listé ici est une OBLIGATION de tous les backends de jetons : il est
|
|
13
|
+
* inscrit dans {@link ITokenListQuery}, et le store mémoire, SQL, Mongo comme
|
|
14
|
+
* Redis l'honorent chacun à sa façon (`WHERE` indexé, prédicat, filtre inline de
|
|
15
|
+
* batch `SCAN`). Le déclarer par store laisserait croire qu'il est facultatif.
|
|
16
|
+
*
|
|
17
|
+
* **Ce qui n'y est PAS, et pourquoi** : `kind`. Il existe bien au contrat, mais
|
|
18
|
+
* l'endpoint d'administration des clés d'API passe par `listPagePat`, qui impose
|
|
19
|
+
* `kind: "pat"` (`service/apiKeys.ts:210`). L'exposer donnerait un filtre que le
|
|
20
|
+
* service écrase en silence — la faute même que ce chantier corrige.
|
|
21
|
+
*/
|
|
22
|
+
const TOKEN_FILTERS = {
|
|
23
|
+
/** Restreint à un porteur (colonne indexée dans tous les backends SQL). */
|
|
24
|
+
subjectId: "string",
|
|
25
|
+
/**
|
|
26
|
+
* État de vie de la clé — la liste fermée vaut allowlist.
|
|
27
|
+
*
|
|
28
|
+
* Remplace l'ancien `revoked: "boolean"`, qui ne distinguait pas une clé
|
|
29
|
+
* ACTIVE d'une clé ARRIVÉE À ÉCHÉANCE : les deux étaient « non révoquées »,
|
|
30
|
+
* alors que la première ouvre l'accès et la seconde ne l'ouvre plus. La
|
|
31
|
+
* console affichait ces deux populations dans des cartes séparées sans
|
|
32
|
+
* pouvoir les demander au serveur.
|
|
33
|
+
*/
|
|
34
|
+
status: [
|
|
35
|
+
"active",
|
|
36
|
+
"expired",
|
|
37
|
+
"revoked"
|
|
38
|
+
]
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* **Les facettes des jetons** — les questions fermées posées à la collection
|
|
42
|
+
* ENTIÈRE pour les cartes de tête.
|
|
43
|
+
*
|
|
44
|
+
* Contrairement aux webhooks, les trois états **partitionnent** : un jeton est
|
|
45
|
+
* dans exactement une case. On les compte tout de même une par une, sans jamais
|
|
46
|
+
* soustraire — une partition est une propriété du domaine d'aujourd'hui, pas une
|
|
47
|
+
* garantie du code, et un quatrième état la briserait en silence.
|
|
48
|
+
*/
|
|
49
|
+
const TOKEN_FACETS = {
|
|
50
|
+
/** Toutes les clés, quel que soit leur état. */
|
|
51
|
+
total: {},
|
|
52
|
+
/** Utilisables : ni révoquées, ni arrivées à échéance. */
|
|
53
|
+
active: { status: "active" },
|
|
54
|
+
/** Arrivées à échéance sans avoir été révoquées. */
|
|
55
|
+
expired: { status: "expired" },
|
|
56
|
+
/** Révoquées par un administrateur. */
|
|
57
|
+
revoked: { status: "revoked" }
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Ce que l'endpoint de COMPTEURS accepte de filtrer — `TOKEN_FILTERS` **moins**
|
|
61
|
+
* les champs que les facettes décomposent.
|
|
62
|
+
*
|
|
63
|
+
* `status` en est retiré : le demander à un endpoint dont les cartes SONT les
|
|
64
|
+
* états produirait une réponse qui se contredit — un total suivant le filtre, et
|
|
65
|
+
* chaque facette l'écrasant par le sien. Le refuser dit au client ce qui se
|
|
66
|
+
* passe ; l'accepter lui montrerait « 5 clés, dont 538 révoquées ».
|
|
67
|
+
*
|
|
68
|
+
* Un test verrouille l'accord entre cette liste et {@link TOKEN_FACETS}.
|
|
69
|
+
*/
|
|
70
|
+
const TOKEN_STATS_FILTERS = { subjectId: "string" };
|
|
71
|
+
//#endregion
|
|
72
|
+
export { TOKEN_FACETS, TOKEN_FILTERS, TOKEN_STATS_FILTERS };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region nodefony/src/token/tokenSort.ts
|
|
2
|
+
/**
|
|
3
|
+
* **Le vocabulaire de tri des jetons**, en noms PUBLICS — ceux qu'un client écrit
|
|
4
|
+
* dans l'URL (`?order=createdAt:DESC`), jamais des noms de colonne.
|
|
5
|
+
*
|
|
6
|
+
* Il vit ici, chez le propriétaire du contrat (`@nodefony/security`), et non dans
|
|
7
|
+
* chaque backend : c'est ce qui garantit qu'une console de clés d'API offre le
|
|
8
|
+
* même tri, que l'application tourne sur mémoire, SQL ou Mongo. Un store dont le
|
|
9
|
+
* schéma nomme un champ autrement traduit **chez lui** — cf
|
|
10
|
+
* {@link translateTokenOrderMongo}.
|
|
11
|
+
*
|
|
12
|
+
* - `createdAt` — date d'émission, l'axe naturel d'une console de clés ;
|
|
13
|
+
* - `name` — le libellé humain, ce qu'on lit dans la colonne de gauche ;
|
|
14
|
+
* - `subjectId` — regroupe les clés d'un même porteur (vue d'administration) ;
|
|
15
|
+
* - `id` — identifiant public, utile surtout en départage.
|
|
16
|
+
*
|
|
17
|
+
* **Ce qui n'y est PAS, et pourquoi** : `lastUsedAt`, `expiresAt` et `revokedAt`
|
|
18
|
+
* sont *nullables*, et le placement des valeurs absentes n'est pas le même d'un
|
|
19
|
+
* moteur à l'autre — PostgreSQL range les `NULL` en tête d'un tri `DESC`, SQLite
|
|
20
|
+
* et MySQL en queue, et le tri en mémoire (`compareByOrder`) les met en queue
|
|
21
|
+
* dans les deux sens. Les déclarer offrirait donc un tri dont l'ordre
|
|
22
|
+
* dépendrait de la base configurée, ce qui est exactement ce que ce vocabulaire
|
|
23
|
+
* existe pour empêcher. Ils s'ouvriront quand la normalisation « absents en
|
|
24
|
+
* queue » sera portée dans le helper de pagination, pas avant.
|
|
25
|
+
*/
|
|
26
|
+
const TOKEN_SORTABLE_FIELDS = [
|
|
27
|
+
"createdAt",
|
|
28
|
+
"name",
|
|
29
|
+
"subjectId",
|
|
30
|
+
"id"
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Ordre contractuel appliqué quand le client n'en demande aucun : les clés les
|
|
34
|
+
* plus récentes d'abord, départagées par identifiant pour rester **déterministe**
|
|
35
|
+
* à horodatage égal (sans quoi une pagination offset peut sauter ou répéter une
|
|
36
|
+
* ligne entre deux pages).
|
|
37
|
+
*/
|
|
38
|
+
const TOKEN_DEFAULT_ORDER = [["createdAt", "DESC"], ["id", "DESC"]];
|
|
39
|
+
//#endregion
|
|
40
|
+
export { TOKEN_DEFAULT_ORDER, TOKEN_SORTABLE_FIELDS };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region nodefony/src/token/tokenStatus.ts
|
|
2
|
+
/**
|
|
3
|
+
* **La** définition de l'état d'un jeton — un seul exemplaire, pour les backends
|
|
4
|
+
* qui évaluent en mémoire (store mémoire, filtrage inline d'un batch `SCAN`).
|
|
5
|
+
*
|
|
6
|
+
* L'ordre d'évaluation est significatif : **révoqué l'emporte sur expiré**. Une
|
|
7
|
+
* clé révoquée puis arrivée à échéance reste « révoquée » — c'est l'acte
|
|
8
|
+
* d'administration qui décrit ce qui s'est passé, pas l'écoulement du temps.
|
|
9
|
+
*
|
|
10
|
+
* Les backends SQL et Mongo n'appellent pas cette fonction (ils traduisent la
|
|
11
|
+
* condition dans leur langage, sinon il faudrait rapatrier la collection pour la
|
|
12
|
+
* filtrer) : c'est le banc de contrat partagé qui garantit qu'ils disent la même
|
|
13
|
+
* chose qu'elle.
|
|
14
|
+
*
|
|
15
|
+
* @param token - les deux horodatages du jeton.
|
|
16
|
+
* @param now - l'instant de référence (injecté : les tests ne dépendent pas de
|
|
17
|
+
* l'horloge réelle, et un store porte déjà la sienne).
|
|
18
|
+
*/
|
|
19
|
+
function tokenStatusOf(token, now) {
|
|
20
|
+
if (token.revokedAt !== null) return "revoked";
|
|
21
|
+
if (token.expiresAt !== null && token.expiresAt <= now) return "expired";
|
|
22
|
+
return "active";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `true` si le jeton correspond au filtre d'état demandé (`undefined` = tous).
|
|
26
|
+
*
|
|
27
|
+
* @param token - les deux horodatages du jeton.
|
|
28
|
+
* @param status - l'état demandé, ou `undefined` pour ne pas filtrer.
|
|
29
|
+
* @param now - l'instant de référence.
|
|
30
|
+
*/
|
|
31
|
+
function matchesTokenStatus(token, status, now) {
|
|
32
|
+
return status === void 0 || tokenStatusOf(token, now) === status;
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
export { matchesTokenStatus, tokenStatusOf };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { MemoryTokenStore } from "./MemoryTokenStore.js";
|
|
2
|
+
//#region nodefony/src/token/tokenStoreRegistry.ts
|
|
3
|
+
const factories = /* @__PURE__ */ new Map();
|
|
4
|
+
/**
|
|
5
|
+
* Enregistre (ou remplace) la fabrique d'un store de jetons. Appelée par le
|
|
6
|
+
* builtin `memory` au chargement, et par les adapters (drizzle/mongoose/redis)
|
|
7
|
+
* pour les leurs.
|
|
8
|
+
*/
|
|
9
|
+
function registerTokenStore(name, factory) {
|
|
10
|
+
factories.set(name, factory);
|
|
11
|
+
}
|
|
12
|
+
/** Fabrique d'un store par nom, ou `undefined` si inconnu. */
|
|
13
|
+
function getTokenStoreFactory(name) {
|
|
14
|
+
return factories.get(name);
|
|
15
|
+
}
|
|
16
|
+
/** Noms enregistrés (validation boot, introspection Studio, tests). */
|
|
17
|
+
function listTokenStores() {
|
|
18
|
+
return [...factories.keys()];
|
|
19
|
+
}
|
|
20
|
+
registerTokenStore("memory", (ctx) => {
|
|
21
|
+
const days = ctx?.config?.tokenStore?.retentionRevokedDays;
|
|
22
|
+
return typeof days === "number" ? new MemoryTokenStore(Date.now, days * 864e5) : new MemoryTokenStore();
|
|
23
|
+
});
|
|
24
|
+
//#endregion
|
|
25
|
+
export { getTokenStoreFactory, listTokenStores, registerTokenStore };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { assertPageQuery } from "nodefony";
|
|
2
|
+
//#region nodefony/src/totp/MemoryTotpSecretStore.ts
|
|
3
|
+
/**
|
|
4
|
+
* Projette un secret en vue d'introspection — **le seul endroit** où l'on
|
|
5
|
+
* décide ce qui sort d'un store TOTP en mémoire. `secretEnc` et les condensats
|
|
6
|
+
* des codes de récupération n'y figurent pas : seul leur NOMBRE est exposé.
|
|
7
|
+
*
|
|
8
|
+
* @param secret - le secret stocké.
|
|
9
|
+
* @returns la vue publique de l'enrôlement.
|
|
10
|
+
*/
|
|
11
|
+
function toTotpEnrollment(secret) {
|
|
12
|
+
return {
|
|
13
|
+
userId: secret.userId,
|
|
14
|
+
algorithm: secret.algorithm,
|
|
15
|
+
digits: secret.digits,
|
|
16
|
+
period: secret.period,
|
|
17
|
+
confirmedAt: secret.confirmedAt,
|
|
18
|
+
createdAt: secret.createdAt,
|
|
19
|
+
lastUsedAt: secret.lastUsedAt,
|
|
20
|
+
recoveryCodesLeft: secret.recoveryCodes.length
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Applique les filtres d'{@link ITotpListQuery} — sémantique de RÉFÉRENCE. */
|
|
24
|
+
function matchesTotpQuery(secret, query) {
|
|
25
|
+
if (query.confirmed !== void 0 && secret.confirmedAt !== null !== query.confirmed) return false;
|
|
26
|
+
if (query.q !== void 0 && query.q.length > 0) {
|
|
27
|
+
if (!secret.userId.startsWith(query.q)) return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Store de secrets TOTP **en mémoire** — implémentation de référence
|
|
33
|
+
* d'{@link ITotpSecretStore}. Clé = `userId` (un secret par utilisateur).
|
|
34
|
+
*
|
|
35
|
+
* 0 dépendance, idéale pour le développement mono-process et les **tests**. NON
|
|
36
|
+
* partagée entre process et **volatile** → en production multi-process, utiliser
|
|
37
|
+
* un adapter ORM ou Redis. Perf : la `Map` n'existe que si le store est instancié
|
|
38
|
+
* (2FA activé), jamais sur le hot path par requête.
|
|
39
|
+
*/
|
|
40
|
+
var MemoryTotpSecretStore = class {
|
|
41
|
+
/** userId → secret (source de vérité). */
|
|
42
|
+
#byUser = /* @__PURE__ */ new Map();
|
|
43
|
+
findByUser(userId) {
|
|
44
|
+
return Promise.resolve(this.#byUser.get(userId) ?? null);
|
|
45
|
+
}
|
|
46
|
+
save(secret) {
|
|
47
|
+
this.#byUser.set(secret.userId, secret);
|
|
48
|
+
return Promise.resolve();
|
|
49
|
+
}
|
|
50
|
+
update(userId, patch) {
|
|
51
|
+
const secret = this.#byUser.get(userId);
|
|
52
|
+
if (secret) {
|
|
53
|
+
if (patch.confirmedAt !== void 0) secret.confirmedAt = patch.confirmedAt;
|
|
54
|
+
if (patch.recoveryCodes !== void 0) secret.recoveryCodes = patch.recoveryCodes;
|
|
55
|
+
if (patch.lastUsedStep !== void 0) secret.lastUsedStep = patch.lastUsedStep;
|
|
56
|
+
if (patch.lastUsedAt !== void 0) secret.lastUsedAt = patch.lastUsedAt;
|
|
57
|
+
}
|
|
58
|
+
return Promise.resolve();
|
|
59
|
+
}
|
|
60
|
+
delete(userId) {
|
|
61
|
+
this.#byUser.delete(userId);
|
|
62
|
+
return Promise.resolve();
|
|
63
|
+
}
|
|
64
|
+
/** {@inheritDoc ITotpSecretStore.listPage} */
|
|
65
|
+
listPage(query) {
|
|
66
|
+
assertPageQuery(query, "offset");
|
|
67
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
68
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
69
|
+
const matched = [...this.#byUser.values()].filter((s) => matchesTotpQuery(s, query));
|
|
70
|
+
matched.sort((a, b) => b.createdAt - a.createdAt || (a.userId < b.userId ? -1 : a.userId > b.userId ? 1 : 0));
|
|
71
|
+
const items = matched.slice(offset, offset + limit).map(toTotpEnrollment);
|
|
72
|
+
return Promise.resolve({
|
|
73
|
+
items,
|
|
74
|
+
total: query.withTotal === false ? void 0 : matched.length,
|
|
75
|
+
limit,
|
|
76
|
+
offset,
|
|
77
|
+
hasNext: offset + items.length < matched.length
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** {@inheritDoc ITotpSecretStore.countEnrollments} */
|
|
81
|
+
countEnrollments(query) {
|
|
82
|
+
let n = 0;
|
|
83
|
+
for (const secret of this.#byUser.values()) if (matchesTotpQuery(secret, query)) n += 1;
|
|
84
|
+
return Promise.resolve(n);
|
|
85
|
+
}
|
|
86
|
+
/** Instantané sérialisable de l'état courant (pour la persistance fichier). */
|
|
87
|
+
snapshot() {
|
|
88
|
+
return { secrets: [...this.#byUser.values()] };
|
|
89
|
+
}
|
|
90
|
+
/** Remplace l'état par celui d'un instantané. */
|
|
91
|
+
restore(snapshot) {
|
|
92
|
+
this.#byUser.clear();
|
|
93
|
+
for (const secret of snapshot.secrets) this.#byUser.set(secret.userId, secret);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
//#endregion
|
|
97
|
+
export { MemoryTotpSecretStore, MemoryTotpSecretStore as default, matchesTotpQuery, toTotpEnrollment };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { decryptSecret, deriveKey, encryptSecret, generateEphemeralKey } from "../crypto/secretCipher.js";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
//#region nodefony/src/totp/totpCipher.ts
|
|
4
|
+
/**
|
|
5
|
+
* Chiffrement réversible du secret TOTP au repos — façade de domaine au-dessus
|
|
6
|
+
* de la brique générique {@link ../crypto/secretCipher}.
|
|
7
|
+
*
|
|
8
|
+
* Le secret TOTP `K` doit être **relu en clair** par le serveur à chaque
|
|
9
|
+
* vérification de code → il est *chiffré* (AES-256-GCM), jamais haché. Ce module
|
|
10
|
+
* fige le contexte de dérivation HKDF **propre au domaine TOTP** : ne JAMAIS
|
|
11
|
+
* modifier {@link TOTP_DERIVATION} (sel/info), sous peine de rendre illisibles
|
|
12
|
+
* tous les secrets TOTP déjà stockés.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Contexte HKDF figé du domaine TOTP. **Constantes historiques — ne pas changer.**
|
|
16
|
+
*/
|
|
17
|
+
const TOTP_DERIVATION = {
|
|
18
|
+
salt: Buffer.from("nodefony.totp.hkdf.v1"),
|
|
19
|
+
info: Buffer.from("totp-secret-encryption")
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Dérive la clé AES-256 du domaine TOTP via HKDF-SHA256 (RFC 5869).
|
|
23
|
+
* Déterministe (clé lisible cross-pod). Délègue à {@link deriveKey} avec le
|
|
24
|
+
* contexte figé du domaine TOTP.
|
|
25
|
+
*/
|
|
26
|
+
function deriveTotpKey(material) {
|
|
27
|
+
return deriveKey(material, TOTP_DERIVATION);
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
export { decryptSecret, deriveTotpKey, encryptSecret, generateEphemeralKey };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
//#region nodefony/src/totp/totpCrypto.ts
|
|
3
|
+
/** Paramètres TOTP — défauts interopérables (Google Authenticator). */
|
|
4
|
+
const TOTP_DEFAULTS = {
|
|
5
|
+
/** Taille du secret en octets (RFC 4226 R6 : 160 bits recommandés). */
|
|
6
|
+
secretBytes: 20,
|
|
7
|
+
/** Période d'un code en secondes (RFC 6238 §5.2 : 30 s recommandé). */
|
|
8
|
+
step: 30,
|
|
9
|
+
/** Nombre de chiffres du code (RFC 4226 §5.3 : 6 minimum). */
|
|
10
|
+
digits: 6,
|
|
11
|
+
/** Fonction de hachage HMAC (compat maximale = SHA1). */
|
|
12
|
+
algorithm: "SHA1",
|
|
13
|
+
/** Origine du décompte des tranches (RFC 6238 : T0 = 0 = epoch Unix). */
|
|
14
|
+
t0: 0,
|
|
15
|
+
/**
|
|
16
|
+
* Tolérance de dérive d'horloge, en nombre de pas de part et d'autre.
|
|
17
|
+
* RFC 6238 §5.2 : « at most one time step » → ±1 (1 pas = 30 s avant/après).
|
|
18
|
+
*/
|
|
19
|
+
window: 1
|
|
20
|
+
};
|
|
21
|
+
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
22
|
+
/**
|
|
23
|
+
* Encode des octets en base32 RFC 4648 **sans padding** (`=`) — forme attendue
|
|
24
|
+
* par les applications d'authentification dans l'URI `otpauth://`.
|
|
25
|
+
*/
|
|
26
|
+
function base32Encode(buf) {
|
|
27
|
+
let bits = 0;
|
|
28
|
+
let value = 0;
|
|
29
|
+
let out = "";
|
|
30
|
+
for (let i = 0; i < buf.length; i++) {
|
|
31
|
+
value = value << 8 | buf[i];
|
|
32
|
+
bits += 8;
|
|
33
|
+
while (bits >= 5) {
|
|
34
|
+
out += BASE32_ALPHABET[value >>> bits - 5 & 31];
|
|
35
|
+
bits -= 5;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (bits > 0) out += BASE32_ALPHABET[value << 5 - bits & 31];
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Décode une chaîne base32 RFC 4648 → octets. Tolérant : ignore espaces,
|
|
43
|
+
* tirets et padding `=`, insensible à la casse (un secret peut être saisi à la
|
|
44
|
+
* main par l'utilisateur). Lève si un caractère hors alphabet est rencontré.
|
|
45
|
+
*/
|
|
46
|
+
function base32Decode(input) {
|
|
47
|
+
const clean = input.replace(/[\s=-]/g, "").toUpperCase();
|
|
48
|
+
let bits = 0;
|
|
49
|
+
let value = 0;
|
|
50
|
+
const out = [];
|
|
51
|
+
for (let i = 0; i < clean.length; i++) {
|
|
52
|
+
const idx = BASE32_ALPHABET.indexOf(clean[i]);
|
|
53
|
+
if (idx === -1) throw new Error(`base32: caractère invalide « ${clean[i]} »`);
|
|
54
|
+
value = value << 5 | idx;
|
|
55
|
+
bits += 5;
|
|
56
|
+
if (bits >= 8) {
|
|
57
|
+
out.push(value >>> bits - 8 & 255);
|
|
58
|
+
bits -= 8;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return Buffer.from(out);
|
|
62
|
+
}
|
|
63
|
+
/** Génère un secret TOTP cryptographiquement aléatoire (défaut 160 bits). */
|
|
64
|
+
function generateTotpSecret(bytes = TOTP_DEFAULTS.secretBytes) {
|
|
65
|
+
return randomBytes(bytes);
|
|
66
|
+
}
|
|
67
|
+
/** Map nom RFC → identifiant `node:crypto`. */
|
|
68
|
+
function hmacName(algo) {
|
|
69
|
+
return algo === "SHA1" ? "sha1" : algo === "SHA256" ? "sha256" : "sha512";
|
|
70
|
+
}
|
|
71
|
+
/** Encode un compteur 64 bits big-endian (RFC 4226 §5.1 : C sur 8 octets). */
|
|
72
|
+
function counterBuffer(counter) {
|
|
73
|
+
const buf = Buffer.alloc(8);
|
|
74
|
+
buf.writeBigUInt64BE(BigInt(counter));
|
|
75
|
+
return buf;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* HOTP (RFC 4226 §5.3) — `Truncate(HMAC(K, counter))` → code à `digits` chiffres,
|
|
79
|
+
* complété à gauche par des zéros.
|
|
80
|
+
*
|
|
81
|
+
* @param secret - secret partagé `K` (octets bruts).
|
|
82
|
+
* @param counter - compteur `C` (≥ 0).
|
|
83
|
+
* @param digits - longueur du code (défaut 6).
|
|
84
|
+
* @param algorithm - fonction HMAC (défaut SHA1).
|
|
85
|
+
*/
|
|
86
|
+
function hotp(secret, counter, digits = TOTP_DEFAULTS.digits, algorithm = TOTP_DEFAULTS.algorithm) {
|
|
87
|
+
const hs = createHmac(hmacName(algorithm), secret).update(counterBuffer(counter)).digest();
|
|
88
|
+
const offset = hs[hs.length - 1] & 15;
|
|
89
|
+
return (((hs[offset] & 127) << 24 | (hs[offset + 1] & 255) << 16 | (hs[offset + 2] & 255) << 8 | hs[offset + 3] & 255) % 10 ** digits).toString().padStart(digits, "0");
|
|
90
|
+
}
|
|
91
|
+
/** Numéro de tranche temporelle `T` (RFC 6238 §4.2) pour un instant donné. */
|
|
92
|
+
function totpCounter(epochSec, step = TOTP_DEFAULTS.step, t0 = TOTP_DEFAULTS.t0) {
|
|
93
|
+
return Math.floor((epochSec - t0) / step);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Calcule le code TOTP courant (RFC 6238 §4) pour un secret donné.
|
|
97
|
+
*
|
|
98
|
+
* @param secret - secret partagé `K`.
|
|
99
|
+
* @returns le code à `digits` chiffres pour la tranche temporelle courante.
|
|
100
|
+
*/
|
|
101
|
+
function totpCode(secret, opts = {}) {
|
|
102
|
+
const step = opts.step ?? TOTP_DEFAULTS.step;
|
|
103
|
+
return hotp(secret, totpCounter(Math.floor((opts.epochMs ?? Date.now()) / 1e3), step, opts.t0 ?? TOTP_DEFAULTS.t0), opts.digits ?? TOTP_DEFAULTS.digits, opts.algorithm ?? TOTP_DEFAULTS.algorithm);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Vérifie un code TOTP présenté contre une fenêtre de tolérance `±window` pas
|
|
107
|
+
* (RFC 6238 §5.2). Comparaison en **temps constant** (anti-timing). Retourne la
|
|
108
|
+
* tranche `T` validée pour permettre à l'appelant d'appliquer l'anti-rejeu.
|
|
109
|
+
*
|
|
110
|
+
* @param code - code présenté par l'utilisateur (les espaces sont ignorés).
|
|
111
|
+
* @param secret - secret partagé `K`.
|
|
112
|
+
* @param window - nombre de pas de tolérance de part et d'autre (défaut ±1).
|
|
113
|
+
*/
|
|
114
|
+
function verifyTotp(code, secret, opts = {}) {
|
|
115
|
+
const digits = opts.digits ?? TOTP_DEFAULTS.digits;
|
|
116
|
+
const normalized = code.replace(/\s/g, "");
|
|
117
|
+
if (!/^\d+$/.test(normalized) || normalized.length !== digits) return { valid: false };
|
|
118
|
+
const step = opts.step ?? TOTP_DEFAULTS.step;
|
|
119
|
+
const current = totpCounter(Math.floor((opts.epochMs ?? Date.now()) / 1e3), step, opts.t0 ?? TOTP_DEFAULTS.t0);
|
|
120
|
+
const window = opts.window ?? TOTP_DEFAULTS.window;
|
|
121
|
+
const algorithm = opts.algorithm ?? TOTP_DEFAULTS.algorithm;
|
|
122
|
+
const presented = Buffer.from(normalized);
|
|
123
|
+
for (let i = -window; i <= window; i++) {
|
|
124
|
+
const candidate = Buffer.from(hotp(secret, current + i, digits, algorithm));
|
|
125
|
+
if (candidate.length === presented.length && timingSafeEqual(candidate, presented)) return {
|
|
126
|
+
valid: true,
|
|
127
|
+
step: current + i
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return { valid: false };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Construit l'URI `otpauth://totp/{issuer}:{account}?...` encodé dans le QR code
|
|
134
|
+
* d'enrôlement. Le label `issuer:account` et le paramètre `issuer` sont tous deux
|
|
135
|
+
* renseignés (recommandation Key Uri Format pour la compat des lecteurs).
|
|
136
|
+
*/
|
|
137
|
+
function buildOtpauthUri(params) {
|
|
138
|
+
return `otpauth://totp/${encodeURIComponent(params.issuer)}:${encodeURIComponent(params.account)}?${new URLSearchParams({
|
|
139
|
+
secret: params.secretBase32,
|
|
140
|
+
issuer: params.issuer,
|
|
141
|
+
algorithm: params.algorithm ?? TOTP_DEFAULTS.algorithm,
|
|
142
|
+
digits: String(params.digits ?? TOTP_DEFAULTS.digits),
|
|
143
|
+
period: String(params.period ?? TOTP_DEFAULTS.step)
|
|
144
|
+
}).toString()}`;
|
|
145
|
+
}
|
|
146
|
+
/** Alphabet des codes de récupération (Crockford-ish, sans I/L/O/U ambigus). */
|
|
147
|
+
const RECOVERY_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
|
|
148
|
+
/**
|
|
149
|
+
* Tire `count` indices UNIFORMES dans `[0, size)` à partir d'octets aléatoires.
|
|
150
|
+
*
|
|
151
|
+
* Le repli direct (`octet % size`) n'est uniforme que si `size` divise 256. Ici
|
|
152
|
+
* `256 % 30 = 16` : les seize premiers symboles de l'alphabet sortaient une fois
|
|
153
|
+
* sur neuf-deux-cent-cinquante-sixièmes, les quatorze autres une fois sur huit —
|
|
154
|
+
* environ 12 % plus souvent pour les premiers.
|
|
155
|
+
*
|
|
156
|
+
* L'enjeu réel est modeste (l'entropie tombe de 4,9069 à 4,9044 bit par
|
|
157
|
+
* caractère, soit 0,025 bit perdu sur les ~49 d'un code) et n'ouvre aucune
|
|
158
|
+
* attaque praticable. Ce n'est pas la raison de corriger : un tirage biaisé dans
|
|
159
|
+
* du code cryptographique est une dette qui ne se voit plus une fois écrite, et
|
|
160
|
+
* dont le coût explose si l'alphabet change un jour pour une taille moins
|
|
161
|
+
* clémente. Le refus d'échantillon coûte ici quelques octets de plus, une fois
|
|
162
|
+
* par activation de second facteur.
|
|
163
|
+
*
|
|
164
|
+
* @param count - nombre d'indices voulus.
|
|
165
|
+
* @param size - taille de l'alphabet (2 à 256).
|
|
166
|
+
* @param source - fournisseur d'octets aléatoires — paramétrable pour que le
|
|
167
|
+
* refus d'échantillon soit OBSERVABLE en test, faute de quoi on ne
|
|
168
|
+
* prouverait jamais que la garde mord.
|
|
169
|
+
* @returns exactement `count` indices, uniformément distribués.
|
|
170
|
+
*/
|
|
171
|
+
function unbiasedIndices(count, size, source = randomBytes) {
|
|
172
|
+
if (size < 2 || size > 256) throw new RangeError(`taille d'alphabet hors bornes : ${size}`);
|
|
173
|
+
const limit = 256 - 256 % size;
|
|
174
|
+
const out = [];
|
|
175
|
+
while (out.length < count) {
|
|
176
|
+
const bytes = source(count - out.length + 8);
|
|
177
|
+
for (const b of bytes) {
|
|
178
|
+
if (b >= limit) continue;
|
|
179
|
+
out.push(b % size);
|
|
180
|
+
if (out.length === count) break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/** Normalise un code de récupération présenté (casse + séparateurs ignorés). */
|
|
186
|
+
function normalizeRecoveryCode(code) {
|
|
187
|
+
return code.replace(/[\s-]/g, "").toUpperCase();
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Génère `count` codes de récupération à usage unique, lisibles
|
|
191
|
+
* (`XXXXX-XXXXX`, ~50 bits chacun). À présenter **une seule fois** à
|
|
192
|
+
* l'utilisateur ; seul leur condensat est persisté ({@link hashRecoveryCode}).
|
|
193
|
+
*/
|
|
194
|
+
function generateRecoveryCodes(count = 10) {
|
|
195
|
+
const codes = [];
|
|
196
|
+
for (let c = 0; c < count; c++) {
|
|
197
|
+
let raw = "";
|
|
198
|
+
for (const idx of unbiasedIndices(10, 30)) raw += RECOVERY_ALPHABET[idx];
|
|
199
|
+
codes.push(`${raw.slice(0, 5)}-${raw.slice(5)}`);
|
|
200
|
+
}
|
|
201
|
+
return codes;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Condensat au repos d'un code de récupération (`sha256` hex). `sha256` suffit
|
|
205
|
+
* (≈ 50 bits aléatoires non brute-forçables, comme une clé API — pas un mot de
|
|
206
|
+
* passe humain) ; la normalisation rend la vérification insensible casse/tirets.
|
|
207
|
+
*/
|
|
208
|
+
function hashRecoveryCode(code) {
|
|
209
|
+
return createHash("sha256").update(normalizeRecoveryCode(code)).digest("hex");
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Cherche un code de récupération présenté parmi des condensats stockés, en
|
|
213
|
+
* **temps constant par entrée**. Retourne l'index consommé (à retirer du stock),
|
|
214
|
+
* ou `-1` si aucun ne correspond.
|
|
215
|
+
*/
|
|
216
|
+
function matchRecoveryCode(presented, hashes) {
|
|
217
|
+
const target = Buffer.from(hashRecoveryCode(presented));
|
|
218
|
+
let found = -1;
|
|
219
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
220
|
+
const candidate = Buffer.from(hashes[i]);
|
|
221
|
+
if (candidate.length === target.length && timingSafeEqual(candidate, target)) found = i;
|
|
222
|
+
}
|
|
223
|
+
return found;
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
export { TOTP_DEFAULTS, base32Decode, base32Encode, buildOtpauthUri, generateRecoveryCodes, generateTotpSecret, hashRecoveryCode, hotp, matchRecoveryCode, totpCode, totpCounter, unbiasedIndices, verifyTotp };
|