@astram/guard 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/account.controller.d.ts +27 -5
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +99 -26
- package/dist/auth/auth.controller.d.ts +130 -21
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +537 -143
- package/dist/auth/auth.module.d.ts +15 -1
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +212 -30
- package/dist/auth/connections.d.ts +1 -1
- package/dist/auth/connections.d.ts.map +1 -1
- package/dist/auth/connections.js +14 -3
- package/dist/auth/default-session-facts-projector.d.ts +26 -0
- package/dist/auth/default-session-facts-projector.d.ts.map +1 -0
- package/dist/auth/default-session-facts-projector.js +21 -0
- package/dist/auth/mfa-step-up.d.ts +32 -0
- package/dist/auth/mfa-step-up.d.ts.map +1 -0
- package/dist/auth/mfa-step-up.js +109 -0
- package/dist/auth/pending-mfa-store.d.ts +50 -52
- package/dist/auth/pending-mfa-store.d.ts.map +1 -1
- package/dist/auth/pending-mfa-store.js +103 -61
- package/dist/auth/ports/session-facts-projector.d.ts +5 -0
- package/dist/auth/ports/session-facts-projector.d.ts.map +1 -1
- package/dist/auth/rate-limit/canonical-hint.d.ts +24 -0
- package/dist/auth/rate-limit/canonical-hint.d.ts.map +1 -0
- package/dist/auth/rate-limit/canonical-hint.js +63 -0
- package/dist/auth/rate-limit/trust-proxy-check.d.ts +46 -0
- package/dist/auth/rate-limit/trust-proxy-check.d.ts.map +1 -0
- package/dist/auth/rate-limit/trust-proxy-check.js +94 -0
- package/dist/auth/tokens.d.ts +70 -11
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +71 -12
- package/dist/authentication/log-email-sender.d.ts +9 -4
- package/dist/authentication/log-email-sender.d.ts.map +1 -1
- package/dist/authentication/mariadb-api-key-store.d.ts +5 -1
- package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-api-key-store.js +43 -7
- package/dist/authentication/mariadb-credential-resolver.d.ts +79 -65
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-resolver.js +116 -73
- package/dist/authentication/mariadb-credential-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-or-totp-verifier.d.ts +21 -3
- package/dist/authentication/mariadb-or-totp-verifier.d.ts.map +1 -1
- package/dist/authentication/mariadb-or-totp-verifier.js +37 -6
- package/dist/authentication/mariadb-profile-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-session-sweeper.d.ts +14 -1
- package/dist/authentication/mariadb-session-sweeper.d.ts.map +1 -1
- package/dist/authentication/mariadb-session-sweeper.js +29 -11
- package/dist/authentication/noop-email-sender.d.ts +12 -0
- package/dist/authentication/noop-email-sender.d.ts.map +1 -0
- package/dist/authentication/noop-email-sender.js +9 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -1
- package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +3 -1
- package/dist/authz/mariadb-policy-store.js +1 -1
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-record-rule-adapter.js +1 -4
- package/dist/authz/permission-version-cache.d.ts +38 -0
- package/dist/authz/permission-version-cache.d.ts.map +1 -0
- package/dist/authz/permission-version-cache.js +81 -0
- package/dist/authz/wire-record-authorization.d.ts +25 -1
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +55 -2
- package/dist/completeness/catalog-reconciler.d.ts.map +1 -1
- package/dist/config/app-config.d.ts +36 -0
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +44 -4
- package/dist/encryption/local-kek-crypto.d.ts.map +1 -1
- package/dist/enforcement/enforcement.module.d.ts.map +1 -1
- package/dist/enforcement/enforcement.module.js +9 -0
- package/dist/enforcement/engine-error.filter.d.ts +57 -0
- package/dist/enforcement/engine-error.filter.d.ts.map +1 -0
- package/dist/enforcement/engine-error.filter.js +118 -0
- package/dist/enforcement/global-enforcement.guard.d.ts +29 -2
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +143 -5
- package/dist/identity/auth-facts.controller.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.js +2 -3
- package/dist/index.d.ts +13 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +68 -28
- package/dist/internal.d.ts +12 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +16 -0
- package/dist/module/shield.module.d.ts +9 -2
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +47 -38
- package/dist/request-auth/session-facts-als.d.ts +16 -1
- package/dist/request-auth/session-facts-als.d.ts.map +1 -1
- package/dist/request-auth/session-facts-als.js +23 -0
- package/dist/request-auth/session-token-binder.d.ts +166 -10
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +408 -28
- package/dist/secrets/encrypted-file-secrets.d.ts.map +1 -1
- package/dist/secrets/encrypted-file-secrets.js +9 -3
- package/dist/session/hot-revoking-session-record-store.d.ts +46 -0
- package/dist/session/hot-revoking-session-record-store.d.ts.map +1 -0
- package/dist/session/hot-revoking-session-record-store.js +58 -0
- package/dist/session/index.d.ts +3 -1
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +8 -1
- package/dist/session/mariadb-session-record-store.d.ts +6 -0
- package/dist/session/mariadb-session-record-store.d.ts.map +1 -1
- package/dist/session/mariadb-session-record-store.js +16 -1
- package/dist/session/session-lifetimes.d.ts +30 -0
- package/dist/session/session-lifetimes.d.ts.map +1 -0
- package/dist/session/session-lifetimes.js +21 -0
- package/dist/session/session-restriction-store.d.ts +43 -0
- package/dist/session/session-restriction-store.d.ts.map +1 -0
- package/dist/session/session-restriction-store.js +35 -0
- package/package.json +14 -3
- package/dist/auth/account.controller.js.map +0 -1
- package/dist/auth/auth.controller.js.map +0 -1
- package/dist/auth/auth.module.js.map +0 -1
- package/dist/auth/connections.js.map +0 -1
- package/dist/auth/dto.js.map +0 -1
- package/dist/auth/pending-mfa-store.js.map +0 -1
- package/dist/auth/persistence.module.js.map +0 -1
- package/dist/auth/ports/audit-sink.js.map +0 -1
- package/dist/auth/ports/credential-writer.js.map +0 -1
- package/dist/auth/ports/email-sender.js.map +0 -1
- package/dist/auth/ports/membership-checker.js.map +0 -1
- package/dist/auth/ports/mfa-device-store.js.map +0 -1
- package/dist/auth/ports/profile-writer.js.map +0 -1
- package/dist/auth/ports/recovery-code-store.js.map +0 -1
- package/dist/auth/ports/reset-token-store.js.map +0 -1
- package/dist/auth/ports/scope-subtree-resolver.js.map +0 -1
- package/dist/auth/ports/session-facts-projector.js.map +0 -1
- package/dist/auth/ports/session-sweeper.js.map +0 -1
- package/dist/auth/profile-resolver.js.map +0 -1
- package/dist/auth/rate-limit/attempt-limiter.js.map +0 -1
- package/dist/auth/tokens.js.map +0 -1
- package/dist/auth/totp-enrollment.js.map +0 -1
- package/dist/authentication/argon2id-password-hasher.js.map +0 -1
- package/dist/authentication/log-email-sender.js.map +0 -1
- package/dist/authentication/mariadb-credential-resolver.js.map +0 -1
- package/dist/authentication/mariadb-credential-writer.js.map +0 -1
- package/dist/authentication/mariadb-mfa-device-store.js.map +0 -1
- package/dist/authentication/mariadb-or-totp-verifier.js.map +0 -1
- package/dist/authentication/mariadb-profile-resolver.js.map +0 -1
- package/dist/authentication/mariadb-profile-writer.js.map +0 -1
- package/dist/authentication/mariadb-recovery-code-store.js.map +0 -1
- package/dist/authentication/mariadb-reset-token-store.js.map +0 -1
- package/dist/authentication/mariadb-session-sweeper.js.map +0 -1
- package/dist/authz/cross-tenant-grant-writer.js.map +0 -1
- package/dist/authz/list-scope-sql.js.map +0 -1
- package/dist/authz/manifest-operation-authority-adapter.js.map +0 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +0 -1
- package/dist/authz/mariadb-list-scope-adapter.js.map +0 -1
- package/dist/authz/mariadb-policy-store.js.map +0 -1
- package/dist/authz/mariadb-record-rule-adapter.js.map +0 -1
- package/dist/authz/mariadb-record-share-adapter.js.map +0 -1
- package/dist/authz/record-share-cascade.js.map +0 -1
- package/dist/authz/record-share-writer.js.map +0 -1
- package/dist/authz/wire-record-authorization.js.map +0 -1
- package/dist/boot/boot-sequence.js.map +0 -1
- package/dist/boot/boot.module.js.map +0 -1
- package/dist/completeness/catalog-reconciler.js.map +0 -1
- package/dist/completeness/operation-inventory-scanner.js.map +0 -1
- package/dist/completeness/ports/catalog-store.js.map +0 -1
- package/dist/config/app-config.js.map +0 -1
- package/dist/config/config.module.js.map +0 -1
- package/dist/enforcement/classification.decorator.js.map +0 -1
- package/dist/enforcement/core-seam.js.map +0 -1
- package/dist/enforcement/enforcement.module.js.map +0 -1
- package/dist/enforcement/global-enforcement.guard.js.map +0 -1
- package/dist/enforcement/reenroll-allowlist.js.map +0 -1
- package/dist/identity/auth-facts.controller.js.map +0 -1
- package/dist/identity/identity-actions.js.map +0 -1
- package/dist/identity/module-registry.bridge.js.map +0 -1
- package/dist/identity/permission-facts.adapter.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/migrations-dir.js.map +0 -1
- package/dist/module/shield-tokens.js.map +0 -1
- package/dist/module/shield.module.js.map +0 -1
- package/dist/request-auth/request-principal.js.map +0 -1
- package/dist/request-auth/session-facts-als.js.map +0 -1
- package/dist/request-auth/session-token-binder.js.map +0 -1
- package/dist/request-auth/tokens.js.map +0 -1
- package/dist/scope/closure-builder.js.map +0 -1
- package/dist/session/crypto-adapters.js.map +0 -1
- package/dist/session/index.js.map +0 -1
- package/dist/session/mariadb-session-record-store.js.map +0 -1
- package/dist/session/redis-hot-session-store.js.map +0 -1
|
@@ -1,110 +1,124 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `MariaDbCredentialResolver` — the framework-DEFAULT concrete
|
|
3
|
-
* {@link CredentialResolver} (core port) backed by
|
|
4
|
-
* `shield_identity.identity_credential`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* {@link CredentialResolver} (core port) backed by
|
|
4
|
+
* `shield_identity.identity_credential` (+ the confirmed rows of
|
|
5
|
+
* `shield_identity.mfa_device`, through the injected {@link MfaDeviceStore}). Resolves an
|
|
6
|
+
* `identityHint` (username / email) → a {@link ResolvedCredential} the Authentication
|
|
7
|
+
* engine verifies, or `null` for an unknown hint.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `credentialResolver` (+ a concrete `MfaDeviceStore`) via `ShieldModule.forRoot`
|
|
18
|
-
* — NOT by this default. See astram3's `IdentityCredentialResolver`, which reads
|
|
19
|
-
* this same table AND folds in the consumer's confirmed authenticators via
|
|
20
|
-
* `listConfirmedSecrets(mfa_device)` to populate `requiredFactors`. That seam is
|
|
21
|
-
* the supported way to add MFA-at-login on top of this password-only default.
|
|
9
|
+
* ── MFA IS ENFORCED BY DEFAULT (security hardening #7, plan decision D8) ──────────
|
|
10
|
+
* When constructed with an {@link MfaDeviceStore} (the `AuthModule` default wiring injects
|
|
11
|
+
* `SHIELD_MFA_DEVICE_STORE`), a principal with ≥1 CONFIRMED device gets ONE `totp`
|
|
12
|
+
* required factor whose `secret` bundles every confirmed device key, newline-joined — the
|
|
13
|
+
* format `OrTotpVerifier` unpacks to match ANY device. So a user who enrolled MFA through
|
|
14
|
+
* `/account/mfa/devices/*` is challenged on every password login. No confirmed device →
|
|
15
|
+
* `requiredFactors: []` (password-only). A device-store ERROR propagates (fail closed:
|
|
16
|
+
* the authenticator reports `authentication_unavailable` → 503), never "no MFA".
|
|
17
|
+
* Constructed WITHOUT a device store, the resolver is password-only (legacy behaviour).
|
|
22
18
|
*
|
|
23
19
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
-
* THE TABLE CONTRACT (
|
|
25
|
-
* in `shield_identity`; the runtime `shield_core`
|
|
26
|
-
* no new grant). Columns
|
|
27
|
-
* session-table convention):
|
|
20
|
+
* THE TABLE CONTRACT (created by `@astram/identity` migration
|
|
21
|
+
* `1710000000003-CreateIdentityCredential`, in `shield_identity`; the runtime `shield_core`
|
|
22
|
+
* account has DML there — no new grant). Columns (prefix `ic_`):
|
|
28
23
|
*
|
|
29
24
|
* identity_credential
|
|
30
25
|
* ┌────────────────────────┬──────────────┬────────────────────────────────────┐
|
|
31
26
|
* │ column │ type │ notes │
|
|
32
27
|
* ├────────────────────────┼──────────────┼────────────────────────────────────┤
|
|
33
|
-
* │ ic_identity_hint │ VARCHAR(255) │
|
|
28
|
+
* │ ic_identity_hint │ VARCHAR(255) │ PK — the login key (user/email) │
|
|
34
29
|
* │ ic_password_hash │ VARCHAR(255) │ Argon2id encoded hash (verify in) │
|
|
35
30
|
* │ ic_principal_id │ VARCHAR(255) │ → ResolvedCredential.claims.id │
|
|
36
31
|
* │ ic_tenant_id │ VARCHAR(255) │ → claims.tenantId │
|
|
37
|
-
* │ ic_roles │ JSON
|
|
32
|
+
* │ ic_roles │ JSON │ role-id array → claims.roleIds │
|
|
38
33
|
* │ ic_created_at_ms │ BIGINT │ UTC epoch ms (platform sets UTC) │
|
|
39
34
|
* │ ic_updated_at_ms │ BIGINT │ UTC epoch ms │
|
|
40
35
|
* └────────────────────────┴──────────────┴────────────────────────────────────┘
|
|
41
|
-
*
|
|
42
|
-
* (No `ic_totp_secret` column — MFA secrets live in the consumer's `mfa_device`
|
|
43
|
-
* table, resolved by a consumer-plugged resolver, not by this default.)
|
|
36
|
+
* (No TOTP column — device keys live in `mfa_device`, one row per device.)
|
|
44
37
|
*
|
|
45
|
-
* The resolver
|
|
38
|
+
* The resolver:
|
|
46
39
|
* 1. `SELECT ic_password_hash, ic_principal_id, ic_tenant_id, ic_roles
|
|
47
40
|
* FROM shield_identity.identity_credential
|
|
48
41
|
* WHERE ic_identity_hint = ? LIMIT 1` (parameterized — NEVER string-built).
|
|
49
|
-
* 2.
|
|
42
|
+
* 2. Returns `null` when NO row matches — the ONLY "not found" signal
|
|
50
43
|
* (anti-enumeration; MUST NOT throw for an unknown hint; the engine burns a
|
|
51
44
|
* decoy verify on `null` so timing is uniform).
|
|
52
|
-
* 3.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* 5. A store outage (connection/query error) MAY throw — the engine treats a
|
|
59
|
-
* resolver throw as fail-closed `authentication_unavailable` (never a leak).
|
|
60
|
-
* It MUST NOT convert a throw into a `null` (that would make an outage look
|
|
61
|
-
* like "unknown user").
|
|
45
|
+
* 3. Maps a found row → `ResolvedCredential` (`passwordHash`, canonical claims
|
|
46
|
+
* `{ id, tenantId, roleIds }`, `requiredFactors` from the confirmed devices).
|
|
47
|
+
* 4. NEVER logs the hash, a device key, or the raw row.
|
|
48
|
+
* 5. A store outage (credential OR device query) propagates — the engine treats a
|
|
49
|
+
* resolver throw as fail-closed `authentication_unavailable`. It MUST NOT be
|
|
50
|
+
* converted into `null` or into "no MFA".
|
|
62
51
|
*
|
|
63
|
-
* (`claims.kind` is optional — the Internal adapter defaults an absent kind
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* allowlist, never a claim, per core/identity.)
|
|
52
|
+
* (`claims.kind` is optional — the Internal adapter defaults an absent kind. Do NOT
|
|
53
|
+
* put any root flag in the row — root is the broker's server-side allowlist, never a
|
|
54
|
+
* claim, per core/identity.)
|
|
67
55
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
68
56
|
*
|
|
69
57
|
* ADR-0003: the concrete DB lives in guard (Core stays zero-dep). ADR-0013/0014:
|
|
70
|
-
* reads `shield_identity` via the
|
|
58
|
+
* reads `shield_identity` via the runtime `shield_core` account.
|
|
71
59
|
*/
|
|
72
60
|
import type { Pool } from 'mariadb';
|
|
73
61
|
import type { CredentialResolver, ResolvedCredential } from '@astram/core';
|
|
62
|
+
import type { MfaDeviceStore } from '../auth/ports/mfa-device-store.js';
|
|
74
63
|
/** DI token for the resolver — re-exported from the shared auth tokens module. */
|
|
75
64
|
export { SHIELD_CREDENTIAL_RESOLVER } from '../auth/tokens.js';
|
|
76
65
|
/**
|
|
77
|
-
* The concrete MariaDB-backed {@link CredentialResolver}. Constructed with the
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* the injected pool.
|
|
81
|
-
*
|
|
82
|
-
* ⛔ SKELETON: `resolve()` throws until Team A implements the SELECT above.
|
|
66
|
+
* The concrete MariaDB-backed {@link CredentialResolver}. Constructed with the live
|
|
67
|
+
* `shield_core` {@link Pool} and (default wiring) the {@link MfaDeviceStore}; stateless
|
|
68
|
+
* per call apart from those two collaborators.
|
|
83
69
|
*/
|
|
84
70
|
export declare class MariaDbCredentialResolver implements CredentialResolver {
|
|
85
71
|
private readonly pool;
|
|
86
|
-
|
|
72
|
+
/**
|
|
73
|
+
* The device store whose CONFIRMED keys become the login's `totp` required factor.
|
|
74
|
+
* Optional only for back-compat (absent → password-only); `AuthModule` always
|
|
75
|
+
* injects `SHIELD_MFA_DEVICE_STORE`.
|
|
76
|
+
*/
|
|
77
|
+
private readonly mfaDevices?;
|
|
78
|
+
/** Clock (epoch-ms) for the users-table re-probe window. */
|
|
79
|
+
private readonly now;
|
|
80
|
+
/**
|
|
81
|
+
* Until when (epoch-ms) the users JOIN is skipped after the table was found missing (errno
|
|
82
|
+
* 1146). Re-probed after {@link USERS_TABLE_REPROBE_MS}, so a table created later (identity
|
|
83
|
+
* installed or migrated while the process runs) is enforced without a restart.
|
|
84
|
+
*/
|
|
85
|
+
private usersTableMissingUntil;
|
|
86
|
+
constructor(pool: Pool,
|
|
87
|
+
/**
|
|
88
|
+
* The device store whose CONFIRMED keys become the login's `totp` required factor.
|
|
89
|
+
* Optional only for back-compat (absent → password-only); `AuthModule` always
|
|
90
|
+
* injects `SHIELD_MFA_DEVICE_STORE`.
|
|
91
|
+
*/
|
|
92
|
+
mfaDevices?: (Pick<MfaDeviceStore, "listConfirmedSecrets"> | null) | undefined,
|
|
93
|
+
/** Clock (epoch-ms) for the users-table re-probe window. */
|
|
94
|
+
now?: () => number);
|
|
87
95
|
/**
|
|
88
96
|
* Resolve `identityHint` → {@link ResolvedCredential}, or `null` when no such
|
|
89
97
|
* principal exists.
|
|
90
98
|
*
|
|
91
99
|
* - Parameterized SELECT (`WHERE ic_identity_hint = ? LIMIT 1`) — never
|
|
92
100
|
* string-built, so the hint can never inject SQL.
|
|
93
|
-
* - **No row ⇒ `null`** — the ONLY "not found" signal (anti-enumeration
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* -
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* would mask an outage AND make it look like "no such user").
|
|
101
|
-
* - The password hash is NEVER logged; the raw row is never logged. On success
|
|
102
|
-
* it is returned to the engine as a verify input only.
|
|
103
|
-
* - **Password-only:** `requiredFactors` is ALWAYS `[]`. This default provides
|
|
104
|
-
* no second-factor challenge; MFA-at-login is delivered by a consumer-plugged
|
|
105
|
-
* `credentialResolver` (+ `MfaDeviceStore`) — see the class doc header and
|
|
106
|
-
* astram3's `IdentityCredentialResolver`.
|
|
101
|
+
* - **No row ⇒ `null`** — the ONLY "not found" signal (anti-enumeration).
|
|
102
|
+
* - A **query/connection ERROR propagates** (credential or device read) — an outage
|
|
103
|
+
* is NOT an unknown user and NOT "no MFA"; the engine fails closed
|
|
104
|
+
* (`authentication_unavailable`).
|
|
105
|
+
* - **Enrolled MFA is required:** ≥1 confirmed device → one `totp` factor bundling
|
|
106
|
+
* the device keys (newline-joined, for `OrTotpVerifier`).
|
|
107
|
+
* - The password hash and device keys are NEVER logged.
|
|
107
108
|
*/
|
|
108
109
|
resolve(identityHint: string): Promise<ResolvedCredential | null>;
|
|
110
|
+
/**
|
|
111
|
+
* The credential row + the account status, in ONE indexed lookup: the credential by its hint
|
|
112
|
+
* (PK), LEFT JOINed to `shield_app.users` by its PK. An app that keeps no `shield_app.users`
|
|
113
|
+
* table (MariaDB errno 1146) falls back to the credential-only lookup — for 60 s, then the JOIN
|
|
114
|
+
* is probed again — and has no account status to enforce (the same rule as the guard's session
|
|
115
|
+
* restriction store). Any other error propagates (fail closed).
|
|
116
|
+
*/
|
|
117
|
+
private lookup;
|
|
118
|
+
/**
|
|
119
|
+
* The principal's required second factors: one `totp` factor bundling every CONFIRMED
|
|
120
|
+
* device key, or `[]` when none (or no device store wired). A store error propagates.
|
|
121
|
+
*/
|
|
122
|
+
private requiredFactorsFor;
|
|
109
123
|
}
|
|
110
124
|
//# sourceMappingURL=mariadb-credential-resolver.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mariadb-credential-resolver.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-credential-resolver.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"mariadb-credential-resolver.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-credential-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,kBAAkB,EAAkB,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAC3F,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAExE,kFAAkF;AAClF,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AA6D/D;;;;GAIG;AACH,qBAAa,yBAA0B,YAAW,kBAAkB;IAShE,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;IAC5B,4DAA4D;IAC5D,OAAO,CAAC,QAAQ,CAAC,GAAG;IAhBtB;;;;OAIG;IACH,OAAO,CAAC,sBAAsB,CAAK;gBAGhB,IAAI,EAAE,IAAI;IAC3B;;;;OAIG;IACc,UAAU,CAAC,GAAE,IAAI,CAAC,cAAc,EAAE,sBAAsB,CAAC,GAAG,IAAI,aAAA;IACjF,4DAA4D;IAC3C,GAAG,GAAE,MAAM,MAAiB;IAG/C;;;;;;;;;;;;;OAaG;IACG,OAAO,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAoCvE;;;;;;OAMG;YACW,MAAM;IAyBpB;;;OAGG;YACW,kBAAkB;CAQjC"}
|
|
@@ -1,80 +1,72 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
3
|
* `MariaDbCredentialResolver` — the framework-DEFAULT concrete
|
|
4
|
-
* {@link CredentialResolver} (core port) backed by
|
|
5
|
-
* `shield_identity.identity_credential`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* {@link CredentialResolver} (core port) backed by
|
|
5
|
+
* `shield_identity.identity_credential` (+ the confirmed rows of
|
|
6
|
+
* `shield_identity.mfa_device`, through the injected {@link MfaDeviceStore}). Resolves an
|
|
7
|
+
* `identityHint` (username / email) → a {@link ResolvedCredential} the Authentication
|
|
8
|
+
* engine verifies, or `null` for an unknown hint.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `credentialResolver` (+ a concrete `MfaDeviceStore`) via `ShieldModule.forRoot`
|
|
19
|
-
* — NOT by this default. See astram3's `IdentityCredentialResolver`, which reads
|
|
20
|
-
* this same table AND folds in the consumer's confirmed authenticators via
|
|
21
|
-
* `listConfirmedSecrets(mfa_device)` to populate `requiredFactors`. That seam is
|
|
22
|
-
* the supported way to add MFA-at-login on top of this password-only default.
|
|
10
|
+
* ── MFA IS ENFORCED BY DEFAULT (security hardening #7, plan decision D8) ──────────
|
|
11
|
+
* When constructed with an {@link MfaDeviceStore} (the `AuthModule` default wiring injects
|
|
12
|
+
* `SHIELD_MFA_DEVICE_STORE`), a principal with ≥1 CONFIRMED device gets ONE `totp`
|
|
13
|
+
* required factor whose `secret` bundles every confirmed device key, newline-joined — the
|
|
14
|
+
* format `OrTotpVerifier` unpacks to match ANY device. So a user who enrolled MFA through
|
|
15
|
+
* `/account/mfa/devices/*` is challenged on every password login. No confirmed device →
|
|
16
|
+
* `requiredFactors: []` (password-only). A device-store ERROR propagates (fail closed:
|
|
17
|
+
* the authenticator reports `authentication_unavailable` → 503), never "no MFA".
|
|
18
|
+
* Constructed WITHOUT a device store, the resolver is password-only (legacy behaviour).
|
|
23
19
|
*
|
|
24
20
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
-
* THE TABLE CONTRACT (
|
|
26
|
-
* in `shield_identity`; the runtime `shield_core`
|
|
27
|
-
* no new grant). Columns
|
|
28
|
-
* session-table convention):
|
|
21
|
+
* THE TABLE CONTRACT (created by `@astram/identity` migration
|
|
22
|
+
* `1710000000003-CreateIdentityCredential`, in `shield_identity`; the runtime `shield_core`
|
|
23
|
+
* account has DML there — no new grant). Columns (prefix `ic_`):
|
|
29
24
|
*
|
|
30
25
|
* identity_credential
|
|
31
26
|
* ┌────────────────────────┬──────────────┬────────────────────────────────────┐
|
|
32
27
|
* │ column │ type │ notes │
|
|
33
28
|
* ├────────────────────────┼──────────────┼────────────────────────────────────┤
|
|
34
|
-
* │ ic_identity_hint │ VARCHAR(255) │
|
|
29
|
+
* │ ic_identity_hint │ VARCHAR(255) │ PK — the login key (user/email) │
|
|
35
30
|
* │ ic_password_hash │ VARCHAR(255) │ Argon2id encoded hash (verify in) │
|
|
36
31
|
* │ ic_principal_id │ VARCHAR(255) │ → ResolvedCredential.claims.id │
|
|
37
32
|
* │ ic_tenant_id │ VARCHAR(255) │ → claims.tenantId │
|
|
38
|
-
* │ ic_roles │ JSON
|
|
33
|
+
* │ ic_roles │ JSON │ role-id array → claims.roleIds │
|
|
39
34
|
* │ ic_created_at_ms │ BIGINT │ UTC epoch ms (platform sets UTC) │
|
|
40
35
|
* │ ic_updated_at_ms │ BIGINT │ UTC epoch ms │
|
|
41
36
|
* └────────────────────────┴──────────────┴────────────────────────────────────┘
|
|
42
|
-
*
|
|
43
|
-
* (No `ic_totp_secret` column — MFA secrets live in the consumer's `mfa_device`
|
|
44
|
-
* table, resolved by a consumer-plugged resolver, not by this default.)
|
|
37
|
+
* (No TOTP column — device keys live in `mfa_device`, one row per device.)
|
|
45
38
|
*
|
|
46
|
-
* The resolver
|
|
39
|
+
* The resolver:
|
|
47
40
|
* 1. `SELECT ic_password_hash, ic_principal_id, ic_tenant_id, ic_roles
|
|
48
41
|
* FROM shield_identity.identity_credential
|
|
49
42
|
* WHERE ic_identity_hint = ? LIMIT 1` (parameterized — NEVER string-built).
|
|
50
|
-
* 2.
|
|
43
|
+
* 2. Returns `null` when NO row matches — the ONLY "not found" signal
|
|
51
44
|
* (anti-enumeration; MUST NOT throw for an unknown hint; the engine burns a
|
|
52
45
|
* decoy verify on `null` so timing is uniform).
|
|
53
|
-
* 3.
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* 5. A store outage (connection/query error) MAY throw — the engine treats a
|
|
60
|
-
* resolver throw as fail-closed `authentication_unavailable` (never a leak).
|
|
61
|
-
* It MUST NOT convert a throw into a `null` (that would make an outage look
|
|
62
|
-
* like "unknown user").
|
|
46
|
+
* 3. Maps a found row → `ResolvedCredential` (`passwordHash`, canonical claims
|
|
47
|
+
* `{ id, tenantId, roleIds }`, `requiredFactors` from the confirmed devices).
|
|
48
|
+
* 4. NEVER logs the hash, a device key, or the raw row.
|
|
49
|
+
* 5. A store outage (credential OR device query) propagates — the engine treats a
|
|
50
|
+
* resolver throw as fail-closed `authentication_unavailable`. It MUST NOT be
|
|
51
|
+
* converted into `null` or into "no MFA".
|
|
63
52
|
*
|
|
64
|
-
* (`claims.kind` is optional — the Internal adapter defaults an absent kind
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* allowlist, never a claim, per core/identity.)
|
|
53
|
+
* (`claims.kind` is optional — the Internal adapter defaults an absent kind. Do NOT
|
|
54
|
+
* put any root flag in the row — root is the broker's server-side allowlist, never a
|
|
55
|
+
* claim, per core/identity.)
|
|
68
56
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
69
57
|
*
|
|
70
58
|
* ADR-0003: the concrete DB lives in guard (Core stays zero-dep). ADR-0013/0014:
|
|
71
|
-
* reads `shield_identity` via the
|
|
59
|
+
* reads `shield_identity` via the runtime `shield_core` account.
|
|
72
60
|
*/
|
|
73
61
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
74
62
|
exports.MariaDbCredentialResolver = exports.SHIELD_CREDENTIAL_RESOLVER = void 0;
|
|
75
63
|
/** DI token for the resolver — re-exported from the shared auth tokens module. */
|
|
76
64
|
var tokens_js_1 = require("../auth/tokens.js");
|
|
77
65
|
Object.defineProperty(exports, "SHIELD_CREDENTIAL_RESOLVER", { enumerable: true, get: function () { return tokens_js_1.SHIELD_CREDENTIAL_RESOLVER; } });
|
|
66
|
+
/** MariaDB `ER_NO_SUCH_TABLE`: the app keeps no `shield_app.users` table. */
|
|
67
|
+
const ER_NO_SUCH_TABLE = 1146;
|
|
68
|
+
/** How long the credential-only fallback is used before the users JOIN is tried again. */
|
|
69
|
+
const USERS_TABLE_REPROBE_MS = 60_000;
|
|
78
70
|
/**
|
|
79
71
|
* Normalize the stored `ic_roles` into a role-id string array. Accepts BOTH:
|
|
80
72
|
* - a native `string[]` (the mariadb `JSON`-column driver already parsed it);
|
|
@@ -107,17 +99,32 @@ function parseRoleIds(raw, hint) {
|
|
|
107
99
|
return value;
|
|
108
100
|
}
|
|
109
101
|
/**
|
|
110
|
-
* The concrete MariaDB-backed {@link CredentialResolver}. Constructed with the
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* the injected pool.
|
|
114
|
-
*
|
|
115
|
-
* ⛔ SKELETON: `resolve()` throws until Team A implements the SELECT above.
|
|
102
|
+
* The concrete MariaDB-backed {@link CredentialResolver}. Constructed with the live
|
|
103
|
+
* `shield_core` {@link Pool} and (default wiring) the {@link MfaDeviceStore}; stateless
|
|
104
|
+
* per call apart from those two collaborators.
|
|
116
105
|
*/
|
|
117
106
|
class MariaDbCredentialResolver {
|
|
118
107
|
pool;
|
|
119
|
-
|
|
108
|
+
mfaDevices;
|
|
109
|
+
now;
|
|
110
|
+
/**
|
|
111
|
+
* Until when (epoch-ms) the users JOIN is skipped after the table was found missing (errno
|
|
112
|
+
* 1146). Re-probed after {@link USERS_TABLE_REPROBE_MS}, so a table created later (identity
|
|
113
|
+
* installed or migrated while the process runs) is enforced without a restart.
|
|
114
|
+
*/
|
|
115
|
+
usersTableMissingUntil = 0;
|
|
116
|
+
constructor(pool,
|
|
117
|
+
/**
|
|
118
|
+
* The device store whose CONFIRMED keys become the login's `totp` required factor.
|
|
119
|
+
* Optional only for back-compat (absent → password-only); `AuthModule` always
|
|
120
|
+
* injects `SHIELD_MFA_DEVICE_STORE`.
|
|
121
|
+
*/
|
|
122
|
+
mfaDevices,
|
|
123
|
+
/** Clock (epoch-ms) for the users-table re-probe window. */
|
|
124
|
+
now = Date.now) {
|
|
120
125
|
this.pool = pool;
|
|
126
|
+
this.mfaDevices = mfaDevices;
|
|
127
|
+
this.now = now;
|
|
121
128
|
}
|
|
122
129
|
/**
|
|
123
130
|
* Resolve `identityHint` → {@link ResolvedCredential}, or `null` when no such
|
|
@@ -125,46 +132,82 @@ class MariaDbCredentialResolver {
|
|
|
125
132
|
*
|
|
126
133
|
* - Parameterized SELECT (`WHERE ic_identity_hint = ? LIMIT 1`) — never
|
|
127
134
|
* string-built, so the hint can never inject SQL.
|
|
128
|
-
* - **No row ⇒ `null`** — the ONLY "not found" signal (anti-enumeration
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* -
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* would mask an outage AND make it look like "no such user").
|
|
136
|
-
* - The password hash is NEVER logged; the raw row is never logged. On success
|
|
137
|
-
* it is returned to the engine as a verify input only.
|
|
138
|
-
* - **Password-only:** `requiredFactors` is ALWAYS `[]`. This default provides
|
|
139
|
-
* no second-factor challenge; MFA-at-login is delivered by a consumer-plugged
|
|
140
|
-
* `credentialResolver` (+ `MfaDeviceStore`) — see the class doc header and
|
|
141
|
-
* astram3's `IdentityCredentialResolver`.
|
|
135
|
+
* - **No row ⇒ `null`** — the ONLY "not found" signal (anti-enumeration).
|
|
136
|
+
* - A **query/connection ERROR propagates** (credential or device read) — an outage
|
|
137
|
+
* is NOT an unknown user and NOT "no MFA"; the engine fails closed
|
|
138
|
+
* (`authentication_unavailable`).
|
|
139
|
+
* - **Enrolled MFA is required:** ≥1 confirmed device → one `totp` factor bundling
|
|
140
|
+
* the device keys (newline-joined, for `OrTotpVerifier`).
|
|
141
|
+
* - The password hash and device keys are NEVER logged.
|
|
142
142
|
*/
|
|
143
143
|
async resolve(identityHint) {
|
|
144
144
|
// A failed query rejects out of here unchanged (fail-closed): we deliberately
|
|
145
145
|
// do NOT wrap this in a try/catch that returns null — only a genuine "no row"
|
|
146
146
|
// maps to null (below), never a store outage.
|
|
147
|
-
const rows =
|
|
148
|
-
FROM shield_identity.identity_credential
|
|
149
|
-
WHERE ic_identity_hint = ?
|
|
150
|
-
LIMIT 1`, [identityHint]));
|
|
147
|
+
const rows = await this.lookup(identityHint);
|
|
151
148
|
const row = rows[0];
|
|
152
149
|
// Unknown hint → null (anti-enumeration). MUST NOT throw here.
|
|
153
150
|
if (!row) {
|
|
154
151
|
return null;
|
|
155
152
|
}
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
const requiredFactors =
|
|
153
|
+
const roleIds = parseRoleIds(row.ic_roles, identityHint);
|
|
154
|
+
// The device read runs for a disabled account too, so it does the same work as an active one.
|
|
155
|
+
const requiredFactors = await this.requiredFactorsFor(row.ic_principal_id);
|
|
156
|
+
// DISABLED ACCOUNT (security hardening Phase 3): `shield_app.users.u_status` set and not
|
|
157
|
+
// `active` → answered exactly like an unknown hint. The core authenticator then runs its decoy
|
|
158
|
+
// verify and returns the SAME generic rejection as a wrong password — no "disabled" oracle.
|
|
159
|
+
// The MFA step-up and the recovery-code branch re-resolve through this resolver, so they refuse
|
|
160
|
+
// too. No users row (a principal kept elsewhere) → unchanged.
|
|
161
|
+
if (typeof row.u_status === 'string' && row.u_status !== 'active') {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
159
164
|
return {
|
|
160
165
|
passwordHash: row.ic_password_hash,
|
|
161
166
|
claims: {
|
|
162
167
|
id: row.ic_principal_id,
|
|
163
168
|
tenantId: row.ic_tenant_id,
|
|
164
|
-
roleIds
|
|
169
|
+
roleIds,
|
|
165
170
|
},
|
|
166
171
|
requiredFactors,
|
|
167
172
|
};
|
|
168
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* The credential row + the account status, in ONE indexed lookup: the credential by its hint
|
|
176
|
+
* (PK), LEFT JOINed to `shield_app.users` by its PK. An app that keeps no `shield_app.users`
|
|
177
|
+
* table (MariaDB errno 1146) falls back to the credential-only lookup — for 60 s, then the JOIN
|
|
178
|
+
* is probed again — and has no account status to enforce (the same rule as the guard's session
|
|
179
|
+
* restriction store). Any other error propagates (fail closed).
|
|
180
|
+
*/
|
|
181
|
+
async lookup(identityHint) {
|
|
182
|
+
if (this.now() >= this.usersTableMissingUntil) {
|
|
183
|
+
try {
|
|
184
|
+
return (await this.pool.query(`SELECT ic_password_hash, ic_principal_id, ic_tenant_id, ic_roles, u.u_status
|
|
185
|
+
FROM shield_identity.identity_credential
|
|
186
|
+
LEFT JOIN shield_app.users u ON u.u_uuid = ic_principal_id
|
|
187
|
+
WHERE ic_identity_hint = ?
|
|
188
|
+
LIMIT 1`, [identityHint]));
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
if (err.errno !== ER_NO_SUCH_TABLE)
|
|
192
|
+
throw err;
|
|
193
|
+
this.usersTableMissingUntil = this.now() + USERS_TABLE_REPROBE_MS;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return (await this.pool.query(`SELECT ic_password_hash, ic_principal_id, ic_tenant_id, ic_roles
|
|
197
|
+
FROM shield_identity.identity_credential
|
|
198
|
+
WHERE ic_identity_hint = ?
|
|
199
|
+
LIMIT 1`, [identityHint]));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The principal's required second factors: one `totp` factor bundling every CONFIRMED
|
|
203
|
+
* device key, or `[]` when none (or no device store wired). A store error propagates.
|
|
204
|
+
*/
|
|
205
|
+
async requiredFactorsFor(principalId) {
|
|
206
|
+
const store = this.mfaDevices;
|
|
207
|
+
if (store === undefined || store === null)
|
|
208
|
+
return [];
|
|
209
|
+
const keys = (await store.listConfirmedSecrets(principalId)).filter((k) => typeof k === 'string' && k.trim() !== '');
|
|
210
|
+
return keys.length > 0 ? [{ factor: 'totp', secret: keys.join('\n') }] : [];
|
|
211
|
+
}
|
|
169
212
|
}
|
|
170
213
|
exports.MariaDbCredentialResolver = MariaDbCredentialResolver;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mariadb-credential-writer.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-credential-writer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;
|
|
1
|
+
{"version":3,"file":"mariadb-credential-writer.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-credential-writer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AAM3E,qBAAa,uBAAwB,YAAW,gBAAgB;IAE5D,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBADH,IAAI,EAAE,IAAI,EACV,GAAG,GAAE,MAAM,MAAiB;IAGzC,WAAW,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOrE,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;CAOnE"}
|
|
@@ -8,24 +8,42 @@
|
|
|
8
8
|
* matches ANY device secret — delegating the RFC-6238 arithmetic per secret to the
|
|
9
9
|
* wrapped BASE single-secret verifier.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* REPLAY GUARD (security hardening M-B): when a Redis client is supplied (the `AuthModule`
|
|
12
|
+
* default), a matched code is CLAIMED with
|
|
13
|
+
* `SET shield:totp:used:<sha256(deviceKey)>:<code> 1 NX PX 90000` before it counts. A code
|
|
14
|
+
* already claimed inside the window (±1 step ≈ 90 s) is rejected; a Redis ERROR propagates
|
|
15
|
+
* (fail closed — this is the login path, not the per-request hot path, so the caller turns
|
|
16
|
+
* it into `authentication_unavailable`). No device key appears in a Redis key name.
|
|
17
|
+
*
|
|
18
|
+
* On an accepted match it best-effort stamps `stampUsed` for the matched device (matched by
|
|
19
|
+
* its unique secret — the verify signature is `(credential, secret)` and carries no
|
|
13
20
|
* principalId; secrets are high-entropy random so the match is exact). The stamp is
|
|
14
21
|
* fire-and-forget: a stamp failure must NEVER fail a login.
|
|
15
22
|
*/
|
|
23
|
+
import type Redis from 'ioredis';
|
|
16
24
|
import type { Credential, FactorVerdict, FactorVerifier } from '@astram/core';
|
|
17
25
|
import type { MfaDeviceStore } from '../auth/ports/mfa-device-store.js';
|
|
26
|
+
/** How long an accepted code stays claimed: the ±1-step window (3 × 30 s). */
|
|
27
|
+
export declare const TOTP_REPLAY_WINDOW_MS = 90000;
|
|
28
|
+
/** The minimal Redis surface the replay guard needs (`SET … PX … NX`). */
|
|
29
|
+
export type TotpReplayStore = Pick<Redis, 'set'>;
|
|
18
30
|
export declare class OrTotpVerifier implements FactorVerifier {
|
|
19
31
|
/** The single-secret RFC-6238 TOTP verifier (delegated to per device secret). */
|
|
20
32
|
private readonly base;
|
|
21
33
|
/** The device store — only `stampUsed` is needed (best-effort last-used stamp). */
|
|
22
34
|
private readonly devices;
|
|
35
|
+
/** Redis for the replay guard. Absent → no replay guard (legacy behaviour). */
|
|
36
|
+
private readonly replay?;
|
|
23
37
|
readonly factor: "totp";
|
|
24
38
|
constructor(
|
|
25
39
|
/** The single-secret RFC-6238 TOTP verifier (delegated to per device secret). */
|
|
26
40
|
base: FactorVerifier,
|
|
27
41
|
/** The device store — only `stampUsed` is needed (best-effort last-used stamp). */
|
|
28
|
-
devices: Pick<MfaDeviceStore, 'stampUsed'
|
|
42
|
+
devices: Pick<MfaDeviceStore, 'stampUsed'>,
|
|
43
|
+
/** Redis for the replay guard. Absent → no replay guard (legacy behaviour). */
|
|
44
|
+
replay?: (TotpReplayStore | null) | undefined);
|
|
29
45
|
verify(credential: Credential, bundledSecret: string): Promise<FactorVerdict>;
|
|
46
|
+
/** `true` iff this is the first use of the code for the device (or no guard is wired). */
|
|
47
|
+
private claim;
|
|
30
48
|
}
|
|
31
49
|
//# sourceMappingURL=mariadb-or-totp-verifier.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mariadb-or-totp-verifier.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-or-totp-verifier.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"mariadb-or-totp-verifier.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-or-totp-verifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AACjC,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAExE,8EAA8E;AAC9E,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAE5C,0EAA0E;AAC1E,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;AAMjD,qBAAa,cAAe,YAAW,cAAc;IAIjD,iFAAiF;IACjF,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,mFAAmF;IACnF,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,+EAA+E;IAC/E,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IAR1B,QAAQ,CAAC,MAAM,EAAG,MAAM,CAAU;;IAGhC,iFAAiF;IAChE,IAAI,EAAE,cAAc;IACrC,mFAAmF;IAClE,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,CAAC;IAC3D,+EAA+E;IAC9D,MAAM,CAAC,GAAE,eAAe,GAAG,IAAI,aAAA;IAG5C,MAAM,CAAC,UAAU,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAoBnF,0FAA0F;YAC5E,KAAK;CAapB"}
|
|
@@ -9,24 +9,41 @@
|
|
|
9
9
|
* matches ANY device secret — delegating the RFC-6238 arithmetic per secret to the
|
|
10
10
|
* wrapped BASE single-secret verifier.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* REPLAY GUARD (security hardening M-B): when a Redis client is supplied (the `AuthModule`
|
|
13
|
+
* default), a matched code is CLAIMED with
|
|
14
|
+
* `SET shield:totp:used:<sha256(deviceKey)>:<code> 1 NX PX 90000` before it counts. A code
|
|
15
|
+
* already claimed inside the window (±1 step ≈ 90 s) is rejected; a Redis ERROR propagates
|
|
16
|
+
* (fail closed — this is the login path, not the per-request hot path, so the caller turns
|
|
17
|
+
* it into `authentication_unavailable`). No device key appears in a Redis key name.
|
|
18
|
+
*
|
|
19
|
+
* On an accepted match it best-effort stamps `stampUsed` for the matched device (matched by
|
|
20
|
+
* its unique secret — the verify signature is `(credential, secret)` and carries no
|
|
14
21
|
* principalId; secrets are high-entropy random so the match is exact). The stamp is
|
|
15
22
|
* fire-and-forget: a stamp failure must NEVER fail a login.
|
|
16
23
|
*/
|
|
17
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.OrTotpVerifier = void 0;
|
|
25
|
+
exports.OrTotpVerifier = exports.TOTP_REPLAY_WINDOW_MS = void 0;
|
|
26
|
+
const node_crypto_1 = require("node:crypto");
|
|
27
|
+
/** How long an accepted code stays claimed: the ±1-step window (3 × 30 s). */
|
|
28
|
+
exports.TOTP_REPLAY_WINDOW_MS = 90_000;
|
|
29
|
+
function replayKey(deviceKey, code) {
|
|
30
|
+
return `shield:totp:used:${(0, node_crypto_1.createHash)('sha256').update(deviceKey).digest('hex')}:${code}`;
|
|
31
|
+
}
|
|
19
32
|
class OrTotpVerifier {
|
|
20
33
|
base;
|
|
21
34
|
devices;
|
|
35
|
+
replay;
|
|
22
36
|
factor = 'totp';
|
|
23
37
|
constructor(
|
|
24
38
|
/** The single-secret RFC-6238 TOTP verifier (delegated to per device secret). */
|
|
25
39
|
base,
|
|
26
40
|
/** The device store — only `stampUsed` is needed (best-effort last-used stamp). */
|
|
27
|
-
devices
|
|
41
|
+
devices,
|
|
42
|
+
/** Redis for the replay guard. Absent → no replay guard (legacy behaviour). */
|
|
43
|
+
replay) {
|
|
28
44
|
this.base = base;
|
|
29
45
|
this.devices = devices;
|
|
46
|
+
this.replay = replay;
|
|
30
47
|
}
|
|
31
48
|
async verify(credential, bundledSecret) {
|
|
32
49
|
const secrets = String(bundledSecret ?? '')
|
|
@@ -35,14 +52,28 @@ class OrTotpVerifier {
|
|
|
35
52
|
.filter(Boolean);
|
|
36
53
|
for (const secret of secrets) {
|
|
37
54
|
const verdict = await this.base.verify(credential, secret);
|
|
38
|
-
if (verdict.satisfied) {
|
|
55
|
+
if (verdict.satisfied === true) {
|
|
56
|
+
// M-B: claim the code for this device; a code already used in the window is a
|
|
57
|
+
// replay → reject. A Redis error propagates (fail closed).
|
|
58
|
+
if (!(await this.claim(secret, credential)))
|
|
59
|
+
return { satisfied: false };
|
|
39
60
|
// Best-effort last-used stamp for the device whose secret verified; never
|
|
40
61
|
// awaited into the deny path (a stamp failure must not fail the login).
|
|
41
|
-
void this.devices.stampUsed(secret);
|
|
62
|
+
void this.devices.stampUsed(secret).catch(() => undefined);
|
|
42
63
|
return { satisfied: true };
|
|
43
64
|
}
|
|
44
65
|
}
|
|
45
66
|
return { satisfied: false };
|
|
46
67
|
}
|
|
68
|
+
/** `true` iff this is the first use of the code for the device (or no guard is wired). */
|
|
69
|
+
async claim(deviceKey, credential) {
|
|
70
|
+
if (this.replay === undefined || this.replay === null)
|
|
71
|
+
return true;
|
|
72
|
+
const code = credential.payload?.code;
|
|
73
|
+
if (typeof code !== 'string' || code === '')
|
|
74
|
+
return false;
|
|
75
|
+
const res = await this.replay.set(replayKey(deviceKey, code), '1', 'PX', exports.TOTP_REPLAY_WINDOW_MS, 'NX');
|
|
76
|
+
return res === 'OK';
|
|
77
|
+
}
|
|
47
78
|
}
|
|
48
79
|
exports.OrTotpVerifier = OrTotpVerifier;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mariadb-profile-resolver.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-profile-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,eAAe,EAAe,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"mariadb-profile-resolver.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-profile-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,eAAe,EAAe,MAAM,6BAA6B,CAAC;AAcpG,qBAAa,sBAAuB,YAAW,eAAe;IAE1D,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBADH,IAAI,EAAE,IAAI,EACV,GAAG,GAAE,MAAM,MAAiB;IAGzC,OAAO,CAAC,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;YA4BzD,gBAAgB;CAc/B"}
|