@c9up/warden 0.1.17 → 0.1.18

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 (100) hide show
  1. package/dist/AuthManager.d.ts +40 -0
  2. package/dist/AuthManager.d.ts.map +1 -1
  3. package/dist/AuthManager.js +45 -3
  4. package/dist/AuthManager.js.map +1 -1
  5. package/dist/Authenticator.d.ts.map +1 -1
  6. package/dist/Authenticator.js +18 -2
  7. package/dist/Authenticator.js.map +1 -1
  8. package/dist/RememberMeToken.d.ts +99 -0
  9. package/dist/RememberMeToken.d.ts.map +1 -0
  10. package/dist/RememberMeToken.js +167 -0
  11. package/dist/RememberMeToken.js.map +1 -0
  12. package/dist/ResilientBlacklistDriver.d.ts +15 -9
  13. package/dist/ResilientBlacklistDriver.d.ts.map +1 -1
  14. package/dist/ResilientBlacklistDriver.js +53 -15
  15. package/dist/ResilientBlacklistDriver.js.map +1 -1
  16. package/dist/WardenProvider.d.ts +1 -0
  17. package/dist/WardenProvider.d.ts.map +1 -1
  18. package/dist/WardenProvider.js +64 -0
  19. package/dist/WardenProvider.js.map +1 -1
  20. package/dist/bouncer/Bouncer.d.ts +32 -1
  21. package/dist/bouncer/Bouncer.d.ts.map +1 -1
  22. package/dist/bouncer/Bouncer.js +54 -0
  23. package/dist/bouncer/Bouncer.js.map +1 -1
  24. package/dist/config.d.ts +3 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +5 -0
  27. package/dist/config.js.map +1 -1
  28. package/dist/firstcontact/drivers/GitHubDriver.d.ts.map +1 -1
  29. package/dist/firstcontact/drivers/GitHubDriver.js +2 -3
  30. package/dist/firstcontact/drivers/GitHubDriver.js.map +1 -1
  31. package/dist/firstcontact/drivers/GoogleDriver.d.ts.map +1 -1
  32. package/dist/firstcontact/drivers/GoogleDriver.js +2 -3
  33. package/dist/firstcontact/drivers/GoogleDriver.js.map +1 -1
  34. package/dist/firstcontact/types.d.ts +10 -0
  35. package/dist/firstcontact/types.d.ts.map +1 -1
  36. package/dist/firstcontact/types.js +18 -1
  37. package/dist/firstcontact/types.js.map +1 -1
  38. package/dist/index.d.ts +7 -4
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/mfa/MfaManager.d.ts.map +1 -1
  43. package/dist/mfa/MfaManager.js +14 -5
  44. package/dist/mfa/MfaManager.js.map +1 -1
  45. package/dist/mfa/TotpProvider.d.ts +34 -1
  46. package/dist/mfa/TotpProvider.d.ts.map +1 -1
  47. package/dist/mfa/TotpProvider.js +60 -4
  48. package/dist/mfa/TotpProvider.js.map +1 -1
  49. package/dist/mfa/webauthn-codec.d.ts +6 -1
  50. package/dist/mfa/webauthn-codec.d.ts.map +1 -1
  51. package/dist/mfa/webauthn-codec.js +50 -6
  52. package/dist/mfa/webauthn-codec.js.map +1 -1
  53. package/dist/middleware.d.ts.map +1 -1
  54. package/dist/middleware.js +46 -1
  55. package/dist/middleware.js.map +1 -1
  56. package/dist/quasar.d.ts.map +1 -1
  57. package/dist/quasar.js +6 -14
  58. package/dist/quasar.js.map +1 -1
  59. package/dist/strategies/ApiKeyStrategy.d.ts +10 -1
  60. package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
  61. package/dist/strategies/ApiKeyStrategy.js +16 -0
  62. package/dist/strategies/ApiKeyStrategy.js.map +1 -1
  63. package/dist/strategies/BasicAuthStrategy.d.ts +59 -0
  64. package/dist/strategies/BasicAuthStrategy.d.ts.map +1 -0
  65. package/dist/strategies/BasicAuthStrategy.js +97 -0
  66. package/dist/strategies/BasicAuthStrategy.js.map +1 -0
  67. package/dist/strategies/JwtStrategy.d.ts +10 -1
  68. package/dist/strategies/JwtStrategy.d.ts.map +1 -1
  69. package/dist/strategies/JwtStrategy.js +11 -0
  70. package/dist/strategies/JwtStrategy.js.map +1 -1
  71. package/dist/strategies/SessionStrategy.d.ts +58 -2
  72. package/dist/strategies/SessionStrategy.d.ts.map +1 -1
  73. package/dist/strategies/SessionStrategy.js +75 -1
  74. package/dist/strategies/SessionStrategy.js.map +1 -1
  75. package/index.darwin-arm64.node +0 -0
  76. package/index.darwin-x64.node +0 -0
  77. package/index.linux-arm64-gnu.node +0 -0
  78. package/index.linux-x64-gnu.node +0 -0
  79. package/index.win32-x64-msvc.node +0 -0
  80. package/package.json +4 -2
  81. package/src/AuthManager.ts +80 -0
  82. package/src/Authenticator.ts +22 -3
  83. package/src/RememberMeToken.ts +223 -0
  84. package/src/ResilientBlacklistDriver.ts +58 -18
  85. package/src/WardenProvider.ts +90 -0
  86. package/src/bouncer/Bouncer.ts +61 -1
  87. package/src/config.ts +9 -0
  88. package/src/firstcontact/drivers/GitHubDriver.ts +2 -3
  89. package/src/firstcontact/drivers/GoogleDriver.ts +2 -3
  90. package/src/firstcontact/types.ts +24 -0
  91. package/src/index.ts +24 -2
  92. package/src/mfa/MfaManager.ts +15 -8
  93. package/src/mfa/TotpProvider.ts +90 -6
  94. package/src/mfa/webauthn-codec.ts +48 -6
  95. package/src/middleware.ts +55 -2
  96. package/src/quasar.ts +6 -14
  97. package/src/strategies/ApiKeyStrategy.ts +23 -1
  98. package/src/strategies/BasicAuthStrategy.ts +127 -0
  99. package/src/strategies/JwtStrategy.ts +18 -1
  100. package/src/strategies/SessionStrategy.ts +113 -2
@@ -153,5 +153,95 @@ export default class WardenProvider {
153
153
  async boot() {
154
154
  const manager = await this.app.container.resolve(AuthManager);
155
155
  if (manager instanceof AuthManager) setAuth(manager);
156
+ await this.#registerTemplateTags();
156
157
  }
158
+
159
+ /**
160
+ * Publish `@can` / `@cannot` to the template engine, as AdonisJS's bouncer
161
+ * does. A migrated template keeps `@can('PostPolicy.edit', post) … @endcan`
162
+ * unchanged.
163
+ *
164
+ * Both are BLOCK tags and both AWAIT — the check returns a promise — so they
165
+ * render on the async path and raise on a synchronous one, matching upstream
166
+ * (which emits `await bouncer.can(...)`). The checks themselves come from
167
+ * `ctx.bouncer.templateHelpers`, shared into the view per request.
168
+ *
169
+ * Resolved through the container: warden must not depend on the engine, and
170
+ * an app with no template layer simply skips this.
171
+ */
172
+ async #registerTemplateTags(): Promise<void> {
173
+ const engine = await this.#resolveTemplateEngine();
174
+ if (engine === undefined) return;
175
+ for (const tagName of ["can", "cannot"] as const) {
176
+ engine.registerTag({
177
+ tagName,
178
+ block: true,
179
+ seekable: true,
180
+ async compile(_parser, buffer, token) {
181
+ // The shared bag lives in the render scope under `bouncer`.
182
+ const bouncer = token.evaluate(
183
+ "typeof bouncer === 'undefined' ? undefined : bouncer",
184
+ );
185
+ const check = Reflect.get(Object(bouncer), tagName);
186
+ if (typeof check !== "function") {
187
+ // No bouncer on this request: deny rather than render an
188
+ // unguarded body.
189
+ return;
190
+ }
191
+ const args = token.evaluate(`[${token.properties.jsArg}]`);
192
+ const allowed = await check.apply(
193
+ bouncer,
194
+ Array.isArray(args) ? args : [],
195
+ );
196
+ if (allowed === true) buffer.writeRaw(await token.renderBody());
197
+ },
198
+ });
199
+ }
200
+ }
201
+
202
+ /** The template engine, if the host installed one. */
203
+ async #resolveTemplateEngine(): Promise<TemplateEngineLike | undefined> {
204
+ for (const token of ["inker", "view"]) {
205
+ try {
206
+ const binding: unknown = await this.app.container.resolve(token);
207
+ for (const candidate of [
208
+ binding,
209
+ Reflect.get(Object(binding), "_templates"),
210
+ ]) {
211
+ if (
212
+ typeof candidate === "object" &&
213
+ candidate !== null &&
214
+ typeof Reflect.get(candidate, "registerTag") === "function"
215
+ ) {
216
+ return {
217
+ registerTag: Reflect.get(candidate, "registerTag").bind(
218
+ candidate,
219
+ ),
220
+ };
221
+ }
222
+ }
223
+ } catch {
224
+ // A host without a template layer is not an error.
225
+ }
226
+ }
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ /** The only surface warden needs of a template engine. */
232
+ interface TemplateEngineLike {
233
+ registerTag(tag: {
234
+ tagName: string;
235
+ block: boolean;
236
+ seekable: boolean;
237
+ compile(
238
+ parser: unknown,
239
+ buffer: { writeRaw(text: string): void },
240
+ token: {
241
+ properties: { jsArg: string };
242
+ renderBody(): string | Promise<string>;
243
+ evaluate(expression: string): unknown;
244
+ },
245
+ ): void | Promise<void>;
246
+ }): void;
157
247
  }
@@ -38,7 +38,7 @@ export class Bouncer {
38
38
  readonly #policies: Record<string, new () => BasePolicy>;
39
39
  readonly #scope: Scope;
40
40
  readonly #resolver: RightsResolver | undefined;
41
- readonly #containerResolver: PolicyContainerResolver | undefined;
41
+ #containerResolver: PolicyContainerResolver | undefined;
42
42
  readonly #emitter: BouncerEmitter | undefined;
43
43
  /** Memoized resolution — a Bouncer is fixed per `(user, scope)`, so resolve once (D3). */
44
44
  #resolved: Promise<EffectivePermissions> | undefined;
@@ -58,6 +58,19 @@ export class Bouncer {
58
58
  this.#emitter = context?.emitter;
59
59
  }
60
60
 
61
+ /**
62
+ * Point the bouncer at an IoC resolver, so a policy's constructor
63
+ * dependencies are injected (AdonisJS `setContainerResolver`).
64
+ *
65
+ * The constructor takes one too; this is the setter AdonisJS exposes so the
66
+ * HTTP layer can hand over the REQUEST's resolver after the bouncer exists.
67
+ * Passing `undefined` clears it, and policies fall back to `new Policy()`.
68
+ */
69
+ setContainerResolver(containerResolver?: PolicyContainerResolver): this {
70
+ this.#containerResolver = containerResolver;
71
+ return this;
72
+ }
73
+
61
74
  /**
62
75
  * Resolve the user, lazily invoking a resolver callback once and memoizing
63
76
  * the result (Adonis `#getUser`). A plain `UserPayload | null` passes through.
@@ -157,6 +170,53 @@ export class Bouncer {
157
170
  return AuthorizationResponse.deny(message, status);
158
171
  }
159
172
 
173
+ /**
174
+ * The checks a TEMPLATE performs, shared into the view per request so
175
+ * `@can('post.edit', post)` resolves them (AdonisJS shares the same pair
176
+ * under `bouncer`; it names the bag `edgeHelpers`, we do not borrow the
177
+ * engine's name).
178
+ *
179
+ * AdonisJS reads a dotted action as `policy.method` —
180
+ * `'PostPolicy.edit'` is `with('PostPolicy').allows('edit', …)`. Warden,
181
+ * however, keys its ABILITIES with dots (`'post.edit'`), so applying that
182
+ * split blindly would break every warden app.
183
+ *
184
+ * NAMED DEVIATION, strictly more permissive: a registered ability wins, and
185
+ * only an action with no matching ability falls through to the policy
186
+ * split. A migrated AdonisJS template resolves its policies exactly as
187
+ * before, and a warden app keeps its dotted abilities.
188
+ */
189
+ readonly templateHelpers: {
190
+ bouncer: {
191
+ can(action: string, ...args: unknown[]): Promise<boolean>;
192
+ cannot(action: string, ...args: unknown[]): Promise<boolean>;
193
+ };
194
+ } = {
195
+ bouncer: {
196
+ can: (action: string, ...args: unknown[]): Promise<boolean> => {
197
+ const split = this.#splitAction(action);
198
+ return split === undefined
199
+ ? this.allows(action, ...args)
200
+ : this.with(split.policy).allows(split.method, ...args);
201
+ },
202
+ cannot: (action: string, ...args: unknown[]): Promise<boolean> => {
203
+ const split = this.#splitAction(action);
204
+ return split === undefined
205
+ ? this.denies(action, ...args)
206
+ : this.with(split.policy).denies(split.method, ...args);
207
+ },
208
+ },
209
+ };
210
+
211
+ /** `policy.method` for a dotted action that names no registered ability;
212
+ * `undefined` when the action IS an ability (warden keys its own with dots). */
213
+ #splitAction(action: string): { policy: string; method: string } | undefined {
214
+ if (Object.hasOwn(this.#abilities, action)) return undefined;
215
+ const dot = action.indexOf(".");
216
+ if (dot <= 0 || dot === action.length - 1) return undefined;
217
+ return { policy: action.slice(0, dot), method: action.slice(dot + 1) };
218
+ }
219
+
160
220
  /** Open a policy for checks (D8 — fresh policy instance per check). */
161
221
  with(policy: (new () => BasePolicy) | string): PolicyAuthorizer {
162
222
  const factory =
package/src/config.ts CHANGED
@@ -22,6 +22,10 @@ import type { MfaManager } from "./mfa/MfaManager.js";
22
22
  import type { RightsStore, Scope } from "./rights/types.js";
23
23
  import type { ApiKeyConfig } from "./strategies/ApiKeyStrategy.js";
24
24
  import { ApiKeyStrategy } from "./strategies/ApiKeyStrategy.js";
25
+ import {
26
+ type BasicAuthConfig,
27
+ BasicAuthStrategy,
28
+ } from "./strategies/BasicAuthStrategy.js";
25
29
  import { JwtStrategy } from "./strategies/JwtStrategy.js";
26
30
  import type { SessionStrategyConfig } from "./strategies/SessionStrategy.js";
27
31
  import { SessionStrategy } from "./strategies/SessionStrategy.js";
@@ -76,6 +80,11 @@ export function apiKeyGuard(config: ApiKeyConfig): GuardFactory {
76
80
  return new ApiKeyStrategy(config);
77
81
  }
78
82
 
83
+ /** Build an HTTP Basic guard from its config (AdonisJS `basicAuthGuard()`). */
84
+ export function basicAuthGuard(config: BasicAuthConfig): GuardFactory {
85
+ return new BasicAuthStrategy(config);
86
+ }
87
+
79
88
  export interface WardenConfig {
80
89
  /**
81
90
  * AdonisJS-style default guard NAME — the key in {@link WardenConfig.guards}
@@ -8,6 +8,7 @@ import type {
8
8
  OAuthToken,
9
9
  OAuthUser,
10
10
  } from "../types.js";
11
+ import { assertOAuthState } from "../types.js";
11
12
 
12
13
  export class GitHubDriver implements FirstContactDriver {
13
14
  constructor(private config: OAuthConfig) {}
@@ -27,9 +28,7 @@ export class GitHubDriver implements FirstContactDriver {
27
28
  state?: string,
28
29
  expectedState?: string,
29
30
  ): Promise<{ user: OAuthUser; token: OAuthToken }> {
30
- if (expectedState && state !== expectedState) {
31
- throw new Error("OAuth state mismatch — possible CSRF attack");
32
- }
31
+ assertOAuthState(state, expectedState);
33
32
  const tokenRes = await fetch(
34
33
  "https://github.com/login/oauth/access_token",
35
34
  {
@@ -8,6 +8,7 @@ import type {
8
8
  OAuthToken,
9
9
  OAuthUser,
10
10
  } from "../types.js";
11
+ import { assertOAuthState } from "../types.js";
11
12
 
12
13
  export class GoogleDriver implements FirstContactDriver {
13
14
  constructor(private config: OAuthConfig) {}
@@ -30,9 +31,7 @@ export class GoogleDriver implements FirstContactDriver {
30
31
  expectedState?: string,
31
32
  ): Promise<{ user: OAuthUser; token: OAuthToken }> {
32
33
  // CSRF protection: validate state matches what we sent in redirectUrl().
33
- if (expectedState && state !== expectedState) {
34
- throw new Error("OAuth state mismatch — possible CSRF attack");
35
- }
34
+ assertOAuthState(state, expectedState);
36
35
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
37
36
  method: "POST",
38
37
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -36,3 +36,27 @@ export interface FirstContactDriver {
36
36
  expectedState?: string,
37
37
  ): Promise<{ user: OAuthUser; token: OAuthToken }>;
38
38
  }
39
+
40
+ /**
41
+ * Check the OAuth `state` round-trip, failing CLOSED.
42
+ *
43
+ * The check used to be `if (expectedState && state !== expectedState)`, so a
44
+ * caller that passed no expected state got NO CSRF protection and no sign that
45
+ * it was missing. A driver used directly — without the manager, which already
46
+ * refuses — was therefore open by default. Anything that cannot be verified is
47
+ * refused instead.
48
+ */
49
+ export function assertOAuthState(
50
+ state: string | undefined,
51
+ expectedState: string | undefined,
52
+ ): void {
53
+ if (!expectedState) {
54
+ throw new Error(
55
+ "[warden] OAuth callback requires expectedState for CSRF protection. " +
56
+ "Store the state given to redirectUrl() in the session and pass it here.",
57
+ );
58
+ }
59
+ if (state !== expectedState) {
60
+ throw new Error("OAuth state mismatch — possible CSRF attack");
61
+ }
62
+ }
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ export type {
17
17
  AuthStrategy,
18
18
  UserPayload,
19
19
  } from "./AuthManager.js";
20
- export { AuthManager } from "./AuthManager.js";
20
+ export { AuthManager, type WardenEmitter } from "./AuthManager.js";
21
21
  export type { AuthRateLimiterConfig } from "./AuthRateLimiter.js";
22
22
  export { AuthRateLimiter } from "./AuthRateLimiter.js";
23
23
  export { AbilitiesBuilder } from "./bouncer/AbilitiesBuilder.js";
@@ -41,6 +41,7 @@ export type {
41
41
  export type { GuardFactory, WardenConfig } from "./config.js";
42
42
  export {
43
43
  apiKeyGuard,
44
+ basicAuthGuard,
44
45
  defineConfig,
45
46
  jwtGuard,
46
47
  sessionGuard,
@@ -98,8 +99,9 @@ export type {
98
99
  TotpAlgorithm,
99
100
  TotpConfig,
100
101
  TotpEnrollment,
102
+ TotpReplayGuard,
101
103
  } from "./mfa/TotpProvider.js";
102
- export { TotpProvider } from "./mfa/TotpProvider.js";
104
+ export { MemoryTotpReplayGuard, TotpProvider } from "./mfa/TotpProvider.js";
103
105
  export type {
104
106
  AuthenticationOptionsJSON,
105
107
  AuthenticationResponseJSON,
@@ -130,6 +132,19 @@ export {
130
132
  generateRefreshToken,
131
133
  MemoryRefreshTokenDriver,
132
134
  } from "./RefreshTokenStore.js";
135
+ export {
136
+ DEFAULT_SECRET_LENGTH,
137
+ decodeTokenValue,
138
+ encodeTokenValue,
139
+ hashSecret,
140
+ MemoryRememberMeTokenDriver,
141
+ type MintedRememberMeToken,
142
+ mintRememberMeToken,
143
+ type RememberMeTokenDriver,
144
+ type StoredRememberMeToken,
145
+ safeCompareHashes,
146
+ verifyAndRecycleRememberMeToken,
147
+ } from "./RememberMeToken.js";
133
148
  export type {
134
149
  BlacklistDegradeEvent,
135
150
  ResilientBlacklistConfig,
@@ -145,6 +160,13 @@ export type {
145
160
  export { scopeKey } from "./rights/types.js";
146
161
  export type { ApiKeyConfig } from "./strategies/ApiKeyStrategy.js";
147
162
  export { ApiKeyStrategy } from "./strategies/ApiKeyStrategy.js";
163
+ export type { BasicAuthConfig } from "./strategies/BasicAuthStrategy.js";
164
+ export {
165
+ BasicAuthStrategy,
166
+ basicAuthChallenge,
167
+ decodeBasicAuth,
168
+ safeCompare,
169
+ } from "./strategies/BasicAuthStrategy.js";
148
170
  export type { JwtClaims, JwtStrategyConfig } from "./strategies/JwtStrategy.js";
149
171
  export { generateJwtSecret, JwtStrategy } from "./strategies/JwtStrategy.js";
150
172
  export type {
@@ -136,7 +136,7 @@ export class MfaManager {
136
136
  if (!factor || factor.kind !== "totp" || !factor.secret) {
137
137
  return false;
138
138
  }
139
- if (!totp.verify(factor.secret, code)) {
139
+ if (!(await totp.verify(factor.secret, code))) {
140
140
  return false;
141
141
  }
142
142
  await this.#store.save({ ...factor, confirmedAt: Date.now() });
@@ -147,13 +147,20 @@ export class MfaManager {
147
147
  async verifyTotp(userId: string, code: string): Promise<boolean> {
148
148
  const totp = this.#requireTotp();
149
149
  const factors = await this.#store.findByUser(userId);
150
- return factors.some(
151
- (f) =>
152
- f.kind === "totp" &&
153
- f.confirmedAt !== undefined &&
154
- f.secret !== undefined &&
155
- totp.verify(f.secret, code),
156
- );
150
+ // Sequential, not Promise.all: the replay guard must see one accepted
151
+ // code at a time, and stopping at the first match avoids burning the
152
+ // code against a second factor that would then reject it as replayed.
153
+ for (const factor of factors) {
154
+ if (
155
+ factor.kind !== "totp" ||
156
+ factor.confirmedAt === undefined ||
157
+ factor.secret === undefined
158
+ ) {
159
+ continue;
160
+ }
161
+ if (await totp.verify(factor.secret, code)) return true;
162
+ }
163
+ return false;
157
164
  }
158
165
 
159
166
  // ── Backup codes ─────────────────────────────────────────────────
@@ -25,6 +25,57 @@ export interface TotpConfig {
25
25
  * tolerate clock skew. Default `1` (i.e. ±30s with the default period).
26
26
  */
27
27
  window?: number;
28
+ /**
29
+ * Remembers codes already accepted, so one cannot be used twice
30
+ * (RFC 6238 §5.2). Defaults to an in-process guard, which is correct for a
31
+ * single instance; inject a shared store (Redis, the session table) when the
32
+ * app runs on several.
33
+ *
34
+ * Omit deliberately — `replayGuard: null` — to accept replays, e.g. when an
35
+ * outer layer already enforces single use.
36
+ */
37
+ replayGuard?: TotpReplayGuard | null;
38
+ }
39
+
40
+ /**
41
+ * Remembers accepted (secret, time-step) pairs for as long as they could still
42
+ * be replayed.
43
+ *
44
+ * Keys are opaque digests, never the secret itself: a store may be shared, and
45
+ * a guard that leaks the seed is worse than the replay it prevents.
46
+ */
47
+ export interface TotpReplayGuard {
48
+ /** True when this exact code has already been accepted. */
49
+ used(key: string): boolean | Promise<boolean>;
50
+ /** Record it as used; `ttlMs` is how long it could still be replayed. */
51
+ remember(key: string, ttlMs: number): void | Promise<void>;
52
+ }
53
+
54
+ /**
55
+ * In-process replay guard. Entries expire on their own, and a sweep runs
56
+ * whenever one is added, so the set stays bounded by the accept window rather
57
+ * than by how many codes have been tried.
58
+ */
59
+ export class MemoryTotpReplayGuard implements TotpReplayGuard {
60
+ readonly #seen = new Map<string, number>();
61
+
62
+ used(key: string): boolean {
63
+ const expiresAt = this.#seen.get(key);
64
+ if (expiresAt === undefined) return false;
65
+ if (expiresAt <= Date.now()) {
66
+ this.#seen.delete(key);
67
+ return false;
68
+ }
69
+ return true;
70
+ }
71
+
72
+ remember(key: string, ttlMs: number): void {
73
+ const now = Date.now();
74
+ for (const [seenKey, expiresAt] of this.#seen) {
75
+ if (expiresAt <= now) this.#seen.delete(seenKey);
76
+ }
77
+ this.#seen.set(key, now + ttlMs);
78
+ }
28
79
  }
29
80
 
30
81
  export interface TotpEnrollment {
@@ -43,10 +94,18 @@ const DEFAULTS = {
43
94
 
44
95
  export class TotpProvider {
45
96
  readonly kind = "totp" as const;
46
- readonly #cfg: Required<TotpConfig>;
97
+ readonly #cfg: Required<Omit<TotpConfig, "replayGuard">>;
98
+ readonly #replayGuard: TotpReplayGuard | null;
47
99
 
48
100
  constructor(config: TotpConfig = {}) {
49
- this.#cfg = { ...DEFAULTS, ...config };
101
+ const { replayGuard, ...rest } = config;
102
+ this.#cfg = { ...DEFAULTS, ...rest };
103
+ // Defaults ON: a code that works twice is the failure this exists to
104
+ // prevent, so opting out has to be deliberate.
105
+ this.#replayGuard =
106
+ replayGuard === null
107
+ ? null
108
+ : (replayGuard ?? new MemoryTotpReplayGuard());
50
109
  if (this.#cfg.digits < 6 || this.#cfg.digits > 8) {
51
110
  throw new WardenError(
52
111
  "INVALID_CONFIG",
@@ -91,7 +150,11 @@ export class TotpProvider {
91
150
  * Verify a user-supplied code against the secret, accepting codes from the
92
151
  * surrounding `window` time steps to tolerate clock skew. Constant-time.
93
152
  */
94
- verify(secret: string, code: string, atMs: number = Date.now()): boolean {
153
+ async verify(
154
+ secret: string,
155
+ code: string,
156
+ atMs: number = Date.now(),
157
+ ): Promise<boolean> {
95
158
  const normalized = code.replace(/\s/g, "");
96
159
  if (normalized.length !== this.#cfg.digits) {
97
160
  return false;
@@ -99,13 +162,34 @@ export class TotpProvider {
99
162
  const key = base32Decode(secret);
100
163
  const current = Math.floor(atMs / 1000 / this.#cfg.period);
101
164
  for (let offset = -this.#cfg.window; offset <= this.#cfg.window; offset++) {
102
- if (constantTimeEqual(this.#hotp(key, current + offset), normalized)) {
103
- return true;
104
- }
165
+ const counter = current + offset;
166
+ if (!constantTimeEqual(this.#hotp(key, counter), normalized)) continue;
167
+ if (this.#replayGuard === null) return true;
168
+ // A matching code is only accepted ONCE. Without this it stays valid
169
+ // for the whole window — with the default settings, ~90 seconds in
170
+ // which a shoulder-surfed or intercepted code still works.
171
+ const seenKey = this.#replayKey(secret, counter);
172
+ if (await this.#replayGuard.used(seenKey)) return false;
173
+ // Remember it for as long as it could still be replayed: until the
174
+ // last step that would accept it has passed.
175
+ const ttlMs =
176
+ (counter + this.#cfg.window + 1) * this.#cfg.period * 1000 - atMs;
177
+ await this.#replayGuard.remember(seenKey, Math.max(ttlMs, 0));
178
+ return true;
105
179
  }
106
180
  return false;
107
181
  }
108
182
 
183
+ /**
184
+ * An opaque key for one (secret, step) pair. Hashed, so a shared store never
185
+ * holds the seed, and bounded in length whatever the secret looks like.
186
+ */
187
+ #replayKey(secret: string, counter: number): string {
188
+ return createHmac("sha256", secret)
189
+ .update(`totp:${counter}`)
190
+ .digest("base64url");
191
+ }
192
+
109
193
  /** RFC 4226 HOTP: HMAC over the 8-byte counter, dynamically truncated. */
110
194
  #hotp(key: Uint8Array, counter: number): string {
111
195
  const buf = Buffer.alloc(8);
@@ -48,11 +48,31 @@ interface Decoded {
48
48
  next: number;
49
49
  }
50
50
 
51
+ /**
52
+ * How deep a nested item may go.
53
+ *
54
+ * Every item here is attacker-supplied: a registration payload is whatever the
55
+ * browser posted. Without a limit, a run of tag bytes recurses once per byte
56
+ * and overflows the stack — 200 KB was enough to kill the request.
57
+ */
58
+ const MAX_DEPTH = 32;
59
+
51
60
  /**
52
61
  * Decode a single CBOR item starting at `start`. Definite-length only —
53
62
  * indefinite-length items and floats are rejected (WebAuthn uses neither).
63
+ *
64
+ * Every declared length is checked against the bytes actually left. A CBOR
65
+ * header can claim up to 2^64 items in five bytes; believing it meant looping
66
+ * billions of times over a buffer that ended long ago, which cost seconds of
67
+ * blocked event loop per request.
54
68
  */
55
- export function decodeCbor(buf: Buffer, start = 0): Decoded {
69
+ export function decodeCbor(buf: Buffer, start = 0, depth = 0): Decoded {
70
+ if (depth > MAX_DEPTH) {
71
+ throw new Error(`CBOR: nesting deeper than ${MAX_DEPTH} is not supported`);
72
+ }
73
+ if (start < 0 || start >= buf.length) {
74
+ throw new Error("CBOR: item starts past the end of the buffer");
75
+ }
56
76
  const major = buf[start] >> 5;
57
77
  const info = buf[start] & 0x1f;
58
78
  let len: number;
@@ -60,36 +80,56 @@ export function decodeCbor(buf: Buffer, start = 0): Decoded {
60
80
  if (info < 24) {
61
81
  len = info;
62
82
  } else if (info === 24) {
83
+ if (p >= buf.length) throw new Error("CBOR: truncated length");
63
84
  len = buf[p];
64
85
  p += 1;
65
86
  } else if (info === 25) {
87
+ if (p + 2 > buf.length) throw new Error("CBOR: truncated length");
66
88
  len = buf.readUInt16BE(p);
67
89
  p += 2;
68
90
  } else if (info === 26) {
91
+ if (p + 4 > buf.length) throw new Error("CBOR: truncated length");
69
92
  len = buf.readUInt32BE(p);
70
93
  p += 4;
71
94
  } else if (info === 27) {
72
- len = Number(buf.readBigUInt64BE(p));
95
+ if (p + 8 > buf.length) throw new Error("CBOR: truncated length");
96
+ const wide = buf.readBigUInt64BE(p);
97
+ if (wide > BigInt(Number.MAX_SAFE_INTEGER)) {
98
+ throw new Error("CBOR: length exceeds the safe integer range");
99
+ }
100
+ len = Number(wide);
73
101
  p += 8;
74
102
  } else {
75
103
  throw new Error("CBOR: indefinite or reserved length is not supported");
76
104
  }
77
105
 
106
+ /** Reject a declared count the remaining bytes cannot possibly hold. */
107
+ const fits = (perItem: number): void => {
108
+ if (len > (buf.length - p) / perItem) {
109
+ throw new Error("CBOR: declared length exceeds the remaining bytes");
110
+ }
111
+ };
112
+
78
113
  switch (major) {
79
114
  case 0: // unsigned int
80
115
  return { value: len, next: p };
81
116
  case 1: // negative int
82
117
  return { value: -1 - len, next: p };
83
118
  case 2: // byte string
119
+ fits(1);
84
120
  return { value: buf.subarray(p, p + len), next: p + len };
85
121
  case 3: // text string
122
+ fits(1);
86
123
  return { value: buf.toString("utf8", p, p + len), next: p + len };
87
124
  case 4: {
88
125
  // array
126
+ // One item is one byte at minimum, so a count above the bytes left
127
+ // cannot be honest.
128
+ fits(1);
89
129
  const arr: CborValue[] = [];
90
130
  let cur = p;
91
131
  for (let i = 0; i < len; i++) {
92
- const d = decodeCbor(buf, cur);
132
+ const d = decodeCbor(buf, cur, depth + 1);
93
133
  arr.push(d.value);
94
134
  cur = d.next;
95
135
  }
@@ -97,12 +137,14 @@ export function decodeCbor(buf: Buffer, start = 0): Decoded {
97
137
  }
98
138
  case 5: {
99
139
  // map
140
+ // A pair is two bytes at minimum.
141
+ fits(2);
100
142
  const map: CborMap = new Map();
101
143
  let cur = p;
102
144
  for (let i = 0; i < len; i++) {
103
- const k = decodeCbor(buf, cur);
145
+ const k = decodeCbor(buf, cur, depth + 1);
104
146
  cur = k.next;
105
- const v = decodeCbor(buf, cur);
147
+ const v = decodeCbor(buf, cur, depth + 1);
106
148
  cur = v.next;
107
149
  if (typeof k.value !== "number" && typeof k.value !== "string") {
108
150
  throw new Error("CBOR: only integer/text map keys are supported");
@@ -113,7 +155,7 @@ export function decodeCbor(buf: Buffer, start = 0): Decoded {
113
155
  }
114
156
  case 6: {
115
157
  // tag — decode and surface the tagged content
116
- const d = decodeCbor(buf, p);
158
+ const d = decodeCbor(buf, p, depth + 1);
117
159
  return { value: d.value, next: d.next };
118
160
  }
119
161
  case 7: