@astram/guard 0.7.0 → 0.8.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.
Files changed (184) hide show
  1. package/dist/auth/account.controller.d.ts +3 -1
  2. package/dist/auth/account.controller.d.ts.map +1 -1
  3. package/dist/auth/account.controller.js +12 -5
  4. package/dist/auth/auth.controller.d.ts +130 -21
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +537 -143
  7. package/dist/auth/auth.module.d.ts +15 -1
  8. package/dist/auth/auth.module.d.ts.map +1 -1
  9. package/dist/auth/auth.module.js +212 -30
  10. package/dist/auth/connections.d.ts +1 -1
  11. package/dist/auth/connections.d.ts.map +1 -1
  12. package/dist/auth/connections.js +14 -3
  13. package/dist/auth/default-session-facts-projector.d.ts +26 -0
  14. package/dist/auth/default-session-facts-projector.d.ts.map +1 -0
  15. package/dist/auth/default-session-facts-projector.js +21 -0
  16. package/dist/auth/mfa-step-up.d.ts +32 -0
  17. package/dist/auth/mfa-step-up.d.ts.map +1 -0
  18. package/dist/auth/mfa-step-up.js +109 -0
  19. package/dist/auth/pending-mfa-store.d.ts +50 -52
  20. package/dist/auth/pending-mfa-store.d.ts.map +1 -1
  21. package/dist/auth/pending-mfa-store.js +103 -61
  22. package/dist/auth/ports/session-facts-projector.d.ts +5 -0
  23. package/dist/auth/ports/session-facts-projector.d.ts.map +1 -1
  24. package/dist/auth/rate-limit/canonical-hint.d.ts +24 -0
  25. package/dist/auth/rate-limit/canonical-hint.d.ts.map +1 -0
  26. package/dist/auth/rate-limit/canonical-hint.js +63 -0
  27. package/dist/auth/rate-limit/trust-proxy-check.d.ts +46 -0
  28. package/dist/auth/rate-limit/trust-proxy-check.d.ts.map +1 -0
  29. package/dist/auth/rate-limit/trust-proxy-check.js +94 -0
  30. package/dist/auth/tokens.d.ts +70 -11
  31. package/dist/auth/tokens.d.ts.map +1 -1
  32. package/dist/auth/tokens.js +71 -12
  33. package/dist/authentication/log-email-sender.d.ts +9 -4
  34. package/dist/authentication/log-email-sender.d.ts.map +1 -1
  35. package/dist/authentication/mariadb-api-key-store.d.ts +4 -1
  36. package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -1
  37. package/dist/authentication/mariadb-api-key-store.js +31 -7
  38. package/dist/authentication/mariadb-credential-resolver.d.ts +79 -65
  39. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
  40. package/dist/authentication/mariadb-credential-resolver.js +116 -73
  41. package/dist/authentication/mariadb-credential-writer.d.ts.map +1 -1
  42. package/dist/authentication/mariadb-or-totp-verifier.d.ts +21 -3
  43. package/dist/authentication/mariadb-or-totp-verifier.d.ts.map +1 -1
  44. package/dist/authentication/mariadb-or-totp-verifier.js +37 -6
  45. package/dist/authentication/mariadb-profile-resolver.d.ts.map +1 -1
  46. package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
  47. package/dist/authentication/mariadb-session-sweeper.d.ts +14 -1
  48. package/dist/authentication/mariadb-session-sweeper.d.ts.map +1 -1
  49. package/dist/authentication/mariadb-session-sweeper.js +29 -11
  50. package/dist/authentication/noop-email-sender.d.ts +12 -0
  51. package/dist/authentication/noop-email-sender.d.ts.map +1 -0
  52. package/dist/authentication/noop-email-sender.js +9 -0
  53. package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -1
  54. package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -1
  55. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
  56. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +3 -1
  57. package/dist/authz/mariadb-policy-store.js +1 -1
  58. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
  59. package/dist/authz/mariadb-record-rule-adapter.js +1 -4
  60. package/dist/authz/permission-version-cache.d.ts +38 -0
  61. package/dist/authz/permission-version-cache.d.ts.map +1 -0
  62. package/dist/authz/permission-version-cache.js +81 -0
  63. package/dist/authz/wire-record-authorization.d.ts +25 -1
  64. package/dist/authz/wire-record-authorization.d.ts.map +1 -1
  65. package/dist/authz/wire-record-authorization.js +55 -2
  66. package/dist/completeness/catalog-reconciler.d.ts.map +1 -1
  67. package/dist/config/app-config.d.ts +36 -0
  68. package/dist/config/app-config.d.ts.map +1 -1
  69. package/dist/config/app-config.js +44 -4
  70. package/dist/encryption/local-kek-crypto.d.ts.map +1 -1
  71. package/dist/enforcement/enforcement.module.d.ts.map +1 -1
  72. package/dist/enforcement/enforcement.module.js +9 -0
  73. package/dist/enforcement/engine-error.filter.d.ts +57 -0
  74. package/dist/enforcement/engine-error.filter.d.ts.map +1 -0
  75. package/dist/enforcement/engine-error.filter.js +118 -0
  76. package/dist/enforcement/global-enforcement.guard.d.ts +29 -2
  77. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  78. package/dist/enforcement/global-enforcement.guard.js +143 -5
  79. package/dist/identity/auth-facts.controller.d.ts.map +1 -1
  80. package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
  81. package/dist/identity/permission-facts.adapter.js +2 -3
  82. package/dist/index.d.ts +12 -5
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +65 -25
  85. package/dist/module/shield.module.d.ts +9 -2
  86. package/dist/module/shield.module.d.ts.map +1 -1
  87. package/dist/module/shield.module.js +47 -38
  88. package/dist/request-auth/session-facts-als.d.ts +16 -1
  89. package/dist/request-auth/session-facts-als.d.ts.map +1 -1
  90. package/dist/request-auth/session-facts-als.js +23 -0
  91. package/dist/request-auth/session-token-binder.d.ts +166 -10
  92. package/dist/request-auth/session-token-binder.d.ts.map +1 -1
  93. package/dist/request-auth/session-token-binder.js +408 -28
  94. package/dist/secrets/encrypted-file-secrets.d.ts.map +1 -1
  95. package/dist/secrets/encrypted-file-secrets.js +9 -3
  96. package/dist/session/hot-revoking-session-record-store.d.ts +46 -0
  97. package/dist/session/hot-revoking-session-record-store.d.ts.map +1 -0
  98. package/dist/session/hot-revoking-session-record-store.js +58 -0
  99. package/dist/session/index.d.ts +3 -1
  100. package/dist/session/index.d.ts.map +1 -1
  101. package/dist/session/index.js +8 -1
  102. package/dist/session/mariadb-session-record-store.d.ts +6 -0
  103. package/dist/session/mariadb-session-record-store.d.ts.map +1 -1
  104. package/dist/session/mariadb-session-record-store.js +16 -1
  105. package/dist/session/session-lifetimes.d.ts +30 -0
  106. package/dist/session/session-lifetimes.d.ts.map +1 -0
  107. package/dist/session/session-lifetimes.js +21 -0
  108. package/dist/session/session-restriction-store.d.ts +43 -0
  109. package/dist/session/session-restriction-store.d.ts.map +1 -0
  110. package/dist/session/session-restriction-store.js +35 -0
  111. package/package.json +3 -3
  112. package/dist/auth/account.controller.js.map +0 -1
  113. package/dist/auth/auth.controller.js.map +0 -1
  114. package/dist/auth/auth.module.js.map +0 -1
  115. package/dist/auth/connections.js.map +0 -1
  116. package/dist/auth/dto.js.map +0 -1
  117. package/dist/auth/pending-mfa-store.js.map +0 -1
  118. package/dist/auth/persistence.module.js.map +0 -1
  119. package/dist/auth/ports/audit-sink.js.map +0 -1
  120. package/dist/auth/ports/credential-writer.js.map +0 -1
  121. package/dist/auth/ports/email-sender.js.map +0 -1
  122. package/dist/auth/ports/membership-checker.js.map +0 -1
  123. package/dist/auth/ports/mfa-device-store.js.map +0 -1
  124. package/dist/auth/ports/profile-writer.js.map +0 -1
  125. package/dist/auth/ports/recovery-code-store.js.map +0 -1
  126. package/dist/auth/ports/reset-token-store.js.map +0 -1
  127. package/dist/auth/ports/scope-subtree-resolver.js.map +0 -1
  128. package/dist/auth/ports/session-facts-projector.js.map +0 -1
  129. package/dist/auth/ports/session-sweeper.js.map +0 -1
  130. package/dist/auth/profile-resolver.js.map +0 -1
  131. package/dist/auth/rate-limit/attempt-limiter.js.map +0 -1
  132. package/dist/auth/tokens.js.map +0 -1
  133. package/dist/auth/totp-enrollment.js.map +0 -1
  134. package/dist/authentication/argon2id-password-hasher.js.map +0 -1
  135. package/dist/authentication/log-email-sender.js.map +0 -1
  136. package/dist/authentication/mariadb-credential-resolver.js.map +0 -1
  137. package/dist/authentication/mariadb-credential-writer.js.map +0 -1
  138. package/dist/authentication/mariadb-mfa-device-store.js.map +0 -1
  139. package/dist/authentication/mariadb-or-totp-verifier.js.map +0 -1
  140. package/dist/authentication/mariadb-profile-resolver.js.map +0 -1
  141. package/dist/authentication/mariadb-profile-writer.js.map +0 -1
  142. package/dist/authentication/mariadb-recovery-code-store.js.map +0 -1
  143. package/dist/authentication/mariadb-reset-token-store.js.map +0 -1
  144. package/dist/authentication/mariadb-session-sweeper.js.map +0 -1
  145. package/dist/authz/cross-tenant-grant-writer.js.map +0 -1
  146. package/dist/authz/list-scope-sql.js.map +0 -1
  147. package/dist/authz/manifest-operation-authority-adapter.js.map +0 -1
  148. package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +0 -1
  149. package/dist/authz/mariadb-list-scope-adapter.js.map +0 -1
  150. package/dist/authz/mariadb-policy-store.js.map +0 -1
  151. package/dist/authz/mariadb-record-rule-adapter.js.map +0 -1
  152. package/dist/authz/mariadb-record-share-adapter.js.map +0 -1
  153. package/dist/authz/record-share-cascade.js.map +0 -1
  154. package/dist/authz/record-share-writer.js.map +0 -1
  155. package/dist/authz/wire-record-authorization.js.map +0 -1
  156. package/dist/boot/boot-sequence.js.map +0 -1
  157. package/dist/boot/boot.module.js.map +0 -1
  158. package/dist/completeness/catalog-reconciler.js.map +0 -1
  159. package/dist/completeness/operation-inventory-scanner.js.map +0 -1
  160. package/dist/completeness/ports/catalog-store.js.map +0 -1
  161. package/dist/config/app-config.js.map +0 -1
  162. package/dist/config/config.module.js.map +0 -1
  163. package/dist/enforcement/classification.decorator.js.map +0 -1
  164. package/dist/enforcement/core-seam.js.map +0 -1
  165. package/dist/enforcement/enforcement.module.js.map +0 -1
  166. package/dist/enforcement/global-enforcement.guard.js.map +0 -1
  167. package/dist/enforcement/reenroll-allowlist.js.map +0 -1
  168. package/dist/identity/auth-facts.controller.js.map +0 -1
  169. package/dist/identity/identity-actions.js.map +0 -1
  170. package/dist/identity/module-registry.bridge.js.map +0 -1
  171. package/dist/identity/permission-facts.adapter.js.map +0 -1
  172. package/dist/index.js.map +0 -1
  173. package/dist/migrations-dir.js.map +0 -1
  174. package/dist/module/shield-tokens.js.map +0 -1
  175. package/dist/module/shield.module.js.map +0 -1
  176. package/dist/request-auth/request-principal.js.map +0 -1
  177. package/dist/request-auth/session-facts-als.js.map +0 -1
  178. package/dist/request-auth/session-token-binder.js.map +0 -1
  179. package/dist/request-auth/tokens.js.map +0 -1
  180. package/dist/scope/closure-builder.js.map +0 -1
  181. package/dist/session/crypto-adapters.js.map +0 -1
  182. package/dist/session/index.js.map +0 -1
  183. package/dist/session/mariadb-session-record-store.js.map +0 -1
  184. 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 the single
4
- * `shield_identity.identity_credential` table. Resolves an `identityHint`
5
- * (username / email) → a {@link ResolvedCredential} the Authentication engine
6
- * verifies, or `null` for an unknown hint.
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
- * ⛔ PASSWORD-ONLY BY DESIGN. This default resolver reads ONLY the columns the
9
- * framework's shipped seed (`infra/mariadb/init/30-identity-credential.sql`)
10
- * actually creates: `ic_password_hash, ic_principal_id, ic_tenant_id, ic_roles`.
11
- * It therefore ALWAYS returns `requiredFactors: []` — it does NOT provide an
12
- * MFA/second-factor challenge. There is no TOTP column here: the multi-device MFA
13
- * model moved authenticator secrets out of `identity_credential` into a separate
14
- * `mfa_device` table, which the framework ships only as an UNIMPLEMENTED port.
15
- *
16
- * MFA / required-factors are a CONSUMER concern, delivered by plugging a custom
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 (infra seed `infra/mariadb/init/30-identity-credential.sql`,
25
- * in `shield_identity`; the runtime `shield_core` account already has DML there —
26
- * no new grant). Columns per spec §3 (prefix `ic_` to match the repo's
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) │ UNIQUE — the login key (user/email)│
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 / TEXT │ role-id array → claims.roleIds │
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
- * PRIMARY KEY / UNIQUE on ic_identity_hint (single-row resolve by hint).
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's SELECT must:
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. Return `null` when NO row matches — the ONLY "not found" signal
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. Map a found row → `ResolvedCredential`:
53
- * passwordHash = ic_password_hash
54
- * claims = { id: ic_principal_id, tenantId: ic_tenant_id,
55
- * roleIds: <parsed ic_roles> } // canonical Internal claims
56
- * requiredFactors = [] // ALWAYS empty — password-only default (see above)
57
- * 4. NEVER log the hash or the raw row.
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; add
64
- * an `ic_kind` column later if a deployment needs non-`user` principals. Do NOT
65
- * put any root flag in the row — root is the broker's server-side
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 hot-path `shield_core` account.
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
- * live `shield_core` {@link Pool} (via {@link SHIELD_CREDENTIAL_RESOLVER}'s
79
- * factory, which injects {@link SHIELD_DB_POOL}). Stateless per call apart from
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
- constructor(pool: Pool);
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; the
94
- * engine burns a decoy password verify on `null` so an unknown user is
95
- * indistinguishable from a wrong password by content or timing). We do NOT
96
- * throw for an unknown hint.
97
- * - A **query/connection ERROR propagates** (rejects) — an outage is NOT an
98
- * unknown user; the engine treats a resolver throw as fail-closed
99
- * `authentication_unavailable`. We never convert an error into `null` (that
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EACV,kBAAkB,EAElB,kBAAkB,EACnB,MAAM,cAAc,CAAC;AAEtB,kFAAkF;AAClF,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAqD/D;;;;;;;GAOG;AACH,qBAAa,yBAA0B,YAAW,kBAAkB;IACtD,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEvC;;;;;;;;;;;;;;;;;;;;OAoBG;IACG,OAAO,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;CAgCxE"}
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 the single
5
- * `shield_identity.identity_credential` table. Resolves an `identityHint`
6
- * (username / email) → a {@link ResolvedCredential} the Authentication engine
7
- * verifies, or `null` for an unknown hint.
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
- * ⛔ PASSWORD-ONLY BY DESIGN. This default resolver reads ONLY the columns the
10
- * framework's shipped seed (`infra/mariadb/init/30-identity-credential.sql`)
11
- * actually creates: `ic_password_hash, ic_principal_id, ic_tenant_id, ic_roles`.
12
- * It therefore ALWAYS returns `requiredFactors: []` — it does NOT provide an
13
- * MFA/second-factor challenge. There is no TOTP column here: the multi-device MFA
14
- * model moved authenticator secrets out of `identity_credential` into a separate
15
- * `mfa_device` table, which the framework ships only as an UNIMPLEMENTED port.
16
- *
17
- * MFA / required-factors are a CONSUMER concern, delivered by plugging a custom
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 (infra seed `infra/mariadb/init/30-identity-credential.sql`,
26
- * in `shield_identity`; the runtime `shield_core` account already has DML there —
27
- * no new grant). Columns per spec §3 (prefix `ic_` to match the repo's
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) │ UNIQUE — the login key (user/email)│
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 / TEXT │ role-id array → claims.roleIds │
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
- * PRIMARY KEY / UNIQUE on ic_identity_hint (single-row resolve by hint).
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's SELECT must:
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. Return `null` when NO row matches — the ONLY "not found" signal
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. Map a found row → `ResolvedCredential`:
54
- * passwordHash = ic_password_hash
55
- * claims = { id: ic_principal_id, tenantId: ic_tenant_id,
56
- * roleIds: <parsed ic_roles> } // canonical Internal claims
57
- * requiredFactors = [] // ALWAYS empty — password-only default (see above)
58
- * 4. NEVER log the hash or the raw row.
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; add
65
- * an `ic_kind` column later if a deployment needs non-`user` principals. Do NOT
66
- * put any root flag in the row — root is the broker's server-side
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 hot-path `shield_core` account.
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
- * live `shield_core` {@link Pool} (via {@link SHIELD_CREDENTIAL_RESOLVER}'s
112
- * factory, which injects {@link SHIELD_DB_POOL}). Stateless per call apart from
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
- constructor(pool) {
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; the
129
- * engine burns a decoy password verify on `null` so an unknown user is
130
- * indistinguishable from a wrong password by content or timing). We do NOT
131
- * throw for an unknown hint.
132
- * - A **query/connection ERROR propagates** (rejects) — an outage is NOT an
133
- * unknown user; the engine treats a resolver throw as fail-closed
134
- * `authentication_unavailable`. We never convert an error into `null` (that
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 = (await this.pool.query(`SELECT ic_password_hash, ic_principal_id, ic_tenant_id, ic_roles
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
- // Password-only default: no second factor is derived here. MFA is a consumer
157
- // concern (custom resolver + MfaDeviceStore), so requiredFactors is always [].
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: parseRoleIds(row.ic_roles, identityHint),
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;AAI3E,qBAAa,uBAAwB,YAAW,gBAAgB;IAClD,OAAO,CAAC,QAAQ,CAAC,IAAI;IAAQ,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAhC,IAAI,EAAE,IAAI,EAAmB,GAAG,GAAE,MAAM,MAAiB;IAEhF,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"}
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
- * On a match it best-effort stamps `stampUsed` for the matched device (matched by its
12
- * unique secret — the verify signature is `(credential, secret)` and carries no
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;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAExE,qBAAa,cAAe,YAAW,cAAc;IAIjD,iFAAiF;IACjF,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,mFAAmF;IACnF,OAAO,CAAC,QAAQ,CAAC,OAAO;IAN1B,QAAQ,CAAC,MAAM,EAAG,MAAM,CAAU;;IAGhC,iFAAiF;IAChE,IAAI,EAAE,cAAc;IACrC,mFAAmF;IAClE,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,CAAC;IAGvD,MAAM,CAAC,UAAU,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;CAgBpF"}
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
- * On a match it best-effort stamps `stampUsed` for the matched device (matched by its
13
- * unique secret — the verify signature is `(credential, secret)` and carries no
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;AAMpG,qBAAa,sBAAuB,YAAW,eAAe;IAChD,OAAO,CAAC,QAAQ,CAAC,IAAI;IAAQ,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAhC,IAAI,EAAE,IAAI,EAAmB,GAAG,GAAE,MAAM,MAAiB;IAEhF,OAAO,CAAC,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;YA4BzD,gBAAgB;CAc/B"}
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"}