@ultimat3/auth 2.0.0 → 4.0.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/CLAUDE.md CHANGED
@@ -18,6 +18,18 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
18
18
 
19
19
  - Every credential failure throws `loginFailed()` — one code, one cause, one fix. Adding a
20
20
  parameter to it re-opens account enumeration.
21
+ - **A stored hash Bun cannot read is the generic failure, and it burns the same KDF** (`As of
22
+ 2026-08`). `Bun.password.verify` THROWS rather than answering `false` on an unsupported algorithm
23
+ (a Django `pbkdf2_sha256$…` row: `UnsupportedAlgorithm`) or a malformed PHC string
24
+ (`InvalidEncoding`), so `verifyPassword` catching nothing was two faults at once: a bare `Error`
25
+ out of `login()` — a 500 where `loginFailed()`'s `X_UNAUTHENTICATED` belongs — and an enumeration
26
+ oracle on exactly the rows that have not migrated off the legacy scheme, which is the normal
27
+ state of a table mid-migration. `verifyAgainst` (`password.ts`) answers `null` there, and `null`
28
+ joins the no-user branch, `''` with it. Nothing is logged: the algorithm of an unreadable hash is
29
+ the same oracle one layer down. An `AuthError` out of the gate (`X_OVERLOADED`) is **re-thrown**,
30
+ never folded into the failure — a shed is load, not a verdict. Supported-but-old stays a verdict:
31
+ bcrypt verifies natively and `needsRehash` flags it, which is the rehash-on-login lever a legacy
32
+ migration rewrites rows with, and `password.test.ts` pins both halves.
21
33
  - The limiter's table is **bounded**, and the eviction order is part of the guarantee. `ipKey`
22
34
  mints one entry per source address, so half the keys are attacker-chosen and a spray from an
23
35
  IPv6 /64 is a fresh key per attempt. Every bucket carries `forgetAtMs` — window emptied *and*
@@ -182,6 +194,55 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
182
194
  `auth.limiter` around `verifyTotp` — today it is wired only into `login`, so a six-digit code
183
195
  would be the one credential in this package with no lockout. `TotpReplayGuard` is already built
184
196
  and must be the completion's, not a second one.
197
+ - **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true` that reaches it
198
+ anyway** (`X_CONFIG_INVALID`, borrowed from core), `As of 2026-08`. It resolved onto the frozen
199
+ `Auth` and **nothing read it** — `login()` and `signInWithOAuth()` branch on `user.mfaSecret`
200
+ alone and mint `mfaSatisfied: true` otherwise, so `required: true` handed a user who had never
201
+ enrolled a fully-privileged session while reading as "this deployment requires a second factor".
202
+ Enforcing it was the tempting fix and is a **lockout**: `actorFromUser` degrades a session only
203
+ when `mfaSecret !== null`, so an un-enrolled user has no half-authenticated actor to enrol
204
+ through, this package ships no enrolment route to send them to, and `mfaRequired()`'s own `fix:`
205
+ (`verifyTotp({ secret: user.mfaSecret, … })`) cannot be followed with a null secret — a dead
206
+ `fix:`, the defect `oauth-paths.ts` exists to stop. So the unenforceable declaration is refused
207
+ where it is written, exactly as `assertAuthLimiterPolicy` refuses a per-process limiter under a
208
+ fleet-wide lockout. The literal type is the build error and the runtime check is for the JS
209
+ caller and the JSON config the type cannot reach, the split `invariantColumns()`'s Proxy keeps.
210
+ `mfa.issuer` is the half that stayed, and it now has a reader: `enrolTotp(auth, { account })`
211
+ takes the `Auth` every other entry point takes, so the product name an authenticator app shows
212
+ is declared once at `defineAuth` instead of restated at every enrolment.
213
+ - **A TOTP secret that decodes to zero bytes is not a weak key, it is NO key** (`As of 2026-08`).
214
+ `base32Decode` answers `new Uint8Array(0)` for any character outside the alphabet and for `''`,
215
+ and `new Bun.CryptoHasher('sha1', new Uint8Array(0))` is a perfectly valid HMAC — so `totpCode`
216
+ returned a six-digit code derived from nothing, every malformed secret in the table shared that
217
+ one stream, and `verifyTotp` accepted a code an attacker computes without knowing any secret.
218
+ Reachable: `enrolTotp(auth, { account, secret })` takes an imported secret, `builtin-adapter.ts`
219
+ maps the column straight through, and a `mfa_secret text not null default ''` column is not
220
+ `null`, so `login()` still demanded a second factor and then accepted the empty-key code. The
221
+ file's own header comment asserted the opposite ("fails the decode closed") for as long as it was
222
+ wrong. Three answers now, one per caller: `verifyTotp` returns `{ ok: false, step: null }` — the
223
+ **generic failure**, the same rule `verifyAgainst` follows for a stored hash Bun cannot read, so
224
+ a broken row is neither a 500 nor an oracle; `totpCode` throws `X_MFA_SECRET_INVALID`, because
225
+ there is no code an unreadable secret is entitled to; `enrolTotp` throws it too, so the value
226
+ never reaches the table. The secret never reaches `cause:` or `fix:` — it is a credential and
227
+ both are logged. No minimum LENGTH is enforced beyond one byte: 10-byte secrets are what several
228
+ authenticator apps issue, so a 16-byte floor would refuse real enrolments to close nothing the
229
+ zero-byte rule leaves open.
230
+ - **`createTotpReplayGuard`'s table is bounded, and the eviction ORDER is the guarantee.** It
231
+ pruned steps inside one subject's `Set` and never revisited a subject who stopped signing in, so
232
+ the map carried one permanent entry per user for the life of the process. Evicting a subject
233
+ makes a step they have already spent replayable again, so the order cannot be recency of
234
+ insertion: a subject whose every step has fallen below the drift floor is **forgotten** (nothing
235
+ outside ±drift is ever offered to `verifyTotp`, so that entry answers exactly as a missing one),
236
+ and only if that is not enough does `DEFAULT_MAX_TOTP_SUBJECTS` evict live state, sorted by
237
+ newest spent step ascending with a least-recently-seen tie-break — the subject who just proved a
238
+ code is always the last one out. Same shape as the limiter's "a live lockout outranks its own
239
+ deadline". `remember` re-files its subject so the map's iteration order IS that tie-break.
240
+ `maxSubjects` is **normalised before any of that arithmetic runs** (`boundedSubjects`): the cap
241
+ ran on the caller's number unchecked, so `Infinity` left the table exactly as unbounded as it was
242
+ before it was capped, and `NaN` — every comparison against which is false — made the eviction
243
+ loop's `used.size <= evictTo` never true, emptying the table on the first sweep and handing back
244
+ a replay of the code the subject had just spent. Anything that is not a positive finite integer
245
+ takes `DEFAULT_MAX_TOTP_SUBJECTS`; a fraction still floors.
185
246
  - SAML is out of scope permanently: XML-DSig canonicalisation has no Bun native and would need a
186
247
  real dependency. Put an OIDC-speaking bridge in front and register that.
187
248
 
package/README.md CHANGED
@@ -11,7 +11,7 @@ export const auth = defineAuth({
11
11
  adapter: new BuiltinAdapter(), // or MemoryAdapter, or your Better Auth binding
12
12
  session: { absoluteTtlMs: 30 * 864e5, idleTtlMs: 7 * 864e5 },
13
13
  password: { minLength: 12 },
14
- mfa: { issuer: 'Acme' },
14
+ mfa: { issuer: 'Acme' }, // the authenticator app's name; `required` only as `false`
15
15
  providers: ['github', 'google'],
16
16
  link: 'verified-email', // the default; `'never'` is the only other value
17
17
  });
@@ -215,10 +215,13 @@ rows, because `AuthAdapter` has no MFA member and adding one would break every t
215
215
  adapter.
216
216
 
217
217
  ```ts
218
+ import type { Auth } from '@ultimat3/auth';
218
219
  import { createTotpReplayGuard, enrolTotp, generateRecoveryCodes, verifyTotp } from '@ultimat3/auth';
219
220
  import { systemClock } from '@ultimat3/core';
220
221
 
221
- const enrolment = enrolTotp({ issuer: 'Acme', account: 'ada@example.com' });
222
+ declare const auth: Auth; // the `defineAuth` at the top — `enrolTotp` reads `auth.mfa.issuer`
223
+
224
+ const enrolment = enrolTotp(auth, { account: 'ada@example.com' }); // issuer: auth.mfa.issuer
222
225
  // enrolment.uri -> otpauth://… the QR code
223
226
  // enrolment.secret -> base32, store it against the user
224
227
 
@@ -236,12 +239,25 @@ export function secondFactorHolds(userId: string, secret: string, code: string):
236
239
 
237
240
  | Call | Answers |
238
241
  |---|---|
239
- | `enrolTotp({ issuer, account, secret? })` | `{ secret, uri, digits, periodSeconds }` — `secret` omitted mints one |
242
+ | `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
240
243
  | `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
241
- | `createTotpReplayGuard(drift?)` | the in-process `{ isUsed, remember }`; a fleet passes a Redis-backed pair of the same two methods |
244
+ | `createTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
242
245
  | `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
243
246
  | `redeemRecoveryCode(code, hashes)` | the **remaining** hashes, or `null`. Persisting that array is what makes a code single-use |
244
- | `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code |
247
+ | `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code. `totpCode` throws `X_MFA_SECRET_INVALID` on a secret that decodes to zero bytes |
248
+
249
+ **A secret the decoder cannot read verifies nothing, `As of 2026-08`.** `base32Decode` answers
250
+ zero bytes for any character outside the alphabet and for `''`, and an HMAC keyed with zero bytes
251
+ is a valid HMAC — so `totpCode` used to hand back a six-digit code derived from no secret at all,
252
+ one stream *every* malformed row in the table verified against, computable by anyone. Three
253
+ answers now, and they differ because their callers do: `verifyTotp` returns `{ ok: false, step:
254
+ null }` (a broken stored credential is the generic failure, the rule `verifyAgainst` follows for a
255
+ hash Bun cannot read — never a throw into the login path, never an oracle); `totpCode` throws
256
+ `X_MFA_SECRET_INVALID`, because there is no code an unreadable secret is entitled to; and
257
+ `enrolTotp` throws the same code on an imported `secret`, so a value nothing can ever check never
258
+ reaches the table. A minted secret is readable by construction. Note the direction of the failure:
259
+ a `mfa_secret text not null default ''` column now locks that account out of its second factor
260
+ instead of accepting a code nobody had to know — re-enrol it with `enrolTotp(auth, { account })`.
245
261
 
246
262
  `TOTP_DIGITS` (6), `TOTP_STEP_SECONDS` (30) and `TOTP_DRIFT_STEPS` (±1 window) are exported so an
247
263
  app's own copy of the parameters cannot disagree with the verifier's.
@@ -251,6 +267,24 @@ side of now, so without the guard the same six digits log in twice inside a minu
251
267
  answers `{ ok: false, step }` — the step still named — when `usedSteps` already holds it, which is
252
268
  how a replay is told apart from a wrong code.
253
269
 
270
+ The guard's table is **bounded** (`DEFAULT_MAX_TOTP_SUBJECTS`, 10,000), because a per-subject map
271
+ that only ever grows is one process' lifetime away from an OOM. A subject whose every remembered
272
+ step has fallen below the drift floor is *forgotten* — `verifyTotp` can never offer that step
273
+ again, so the entry answers exactly as a missing one — and only if that is not enough does the cap
274
+ evict live state, furthest from the live window first. The order is the guarantee: evicting a
275
+ subject makes a step they have already spent replayable, so the subject who just authenticated is
276
+ always the last one out.
277
+
278
+ **`mfa.required` is accepted only as `false`, and that is deliberate.** The field exists and is
279
+ typed as the literal `false`, so `required: true` is a compile error; a `true` that reaches
280
+ `defineAuth` from JavaScript or from JSON — where the type cannot — is refused at boot with
281
+ `X_CONFIG_INVALID` naming the key, never an unknown-key error and never a silent accept. Nothing
282
+ read it: both credential paths branch on `user.mfaSecret` alone, so an un-enrolled user was handed
283
+ a full session under a config that read as "this deployment requires a second factor". Enforcing it
284
+ at `login()` instead is a lockout — `actorFromUser` degrades only a user who HAS a secret, and this
285
+ package ships no enrolment route to send the rest to. Gate it in your own sign-in handler —
286
+ `if (user.mfaSecret === null)` send them to `enrolTotp` before you call `createSession`.
287
+
254
288
  **The second leg of login is the app's, `As of 2026-08`.** `login()` and `completeOAuthLogin()`
255
289
  throw `X_MFA_REQUIRED` before any session exists; finishing the flow is `verifyTotp` followed by
256
290
  `createSession({ mfaSatisfied: true })` in the app's own route. The framework ships no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "2.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,8 +31,8 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "2.0.0",
35
- "@ultimat3/db": "2.0.0",
36
- "@ultimat3/schema": "2.0.0"
34
+ "@ultimat3/core": "4.0.0",
35
+ "@ultimat3/db": "4.0.0",
36
+ "@ultimat3/schema": "4.0.0"
37
37
  }
38
38
  }
package/src/auth.ts CHANGED
@@ -7,7 +7,7 @@ import { type Clock, systemClock, uuid } from '@ultimat3/core';
7
7
  import { t } from '@ultimat3/schema';
8
8
  import type { AuthAdapter, AuthSession, AuthUser } from './adapter';
9
9
  import { normaliseEmail } from './email';
10
- import { mfaRequired, sessionUnknown } from './errors';
10
+ import { mfaRequired, mfaRequiredUnenforceable, sessionUnknown } from './errors';
11
11
  import type { OAuthProviderId } from './oauth';
12
12
  import { oauthProviderIds } from './oauth-registry';
13
13
  import {
@@ -106,10 +106,25 @@ export const VerificationSchema = t.object({
106
106
  */
107
107
  export type OAuthLinkPolicy = 'verified-email' | 'never';
108
108
 
109
+ /** The product name an authenticator app shows when the app declared none. */
110
+ export const DEFAULT_MFA_ISSUER = 'Ultimate';
111
+
109
112
  export interface AuthMfaPolicy {
110
- /** Shown in the authenticator app. Usually the product name. */
113
+ /**
114
+ * Shown in the authenticator app. Usually the product name, and `enrolTotp(auth, …)` reads it
115
+ * from here so it is written once — a call may still name its own for the one enrolment.
116
+ */
111
117
  readonly issuer: string;
112
- readonly required: boolean;
118
+ /**
119
+ * The literal `false`, so `required: true` is a type error rather than a comment — the shape
120
+ * `OAuthProvider.usesPkce` has, and for the same reason: the value nothing enforces has to be
121
+ * unrepresentable, not discouraged. This package cannot make a second factor mandatory. Both
122
+ * credential paths branch on `user.mfaSecret` alone and `actorFromUser` degrades only a user
123
+ * who HAS a secret, so a user who never enrolled has no half-authenticated actor to enrol
124
+ * through and no enrolment route to reach — refusing them at `login()` locks them out for good.
125
+ * `defineAuth` refuses the declaration outright; the app gates its own sign-in handler.
126
+ */
127
+ readonly required: false;
113
128
  }
114
129
 
115
130
  export interface AuthConfigInput {
@@ -166,6 +181,13 @@ export function defineAuth(config: AuthConfigInput): Auth {
166
181
  const orgLimits = orgRateLimit(rateLimit);
167
182
  const orgLimiter = config.orgLimiter ?? createAuthLimiter(clock, orgLimits);
168
183
  if (config.orgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, config.orgLimiter);
184
+ // Read through a widened local on purpose: the field's type is the literal `false`, so this
185
+ // branch is unreachable from TypeScript and reachable from every JS caller and every config
186
+ // parsed out of JSON — the same split `invariantColumns()` keeps its Proxy behind a compile
187
+ // error for. A declaration this package cannot enforce is refused where it is written.
188
+ const declaredMfa: { readonly required?: unknown } = config.mfa ?? {};
189
+ if (declaredMfa.required === true) throw mfaRequiredUnenforceable();
190
+ const mfa: AuthMfaPolicy = { issuer: config.mfa?.issuer ?? DEFAULT_MFA_ISSUER, required: false };
169
191
  return Object.freeze({
170
192
  adapter: config.adapter,
171
193
  clock,
@@ -177,7 +199,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
177
199
  // exactly when the app declared 'shared' and left this limiter to the default.
178
200
  orgRateLimit: orgLimiter.policy,
179
201
  orgLimiter,
180
- mfa: { issuer: config.mfa?.issuer ?? 'Ultimate', required: config.mfa?.required ?? false },
202
+ mfa,
181
203
  providers: config.providers ?? oauthProviderIds(),
182
204
  link: config.link ?? 'verified-email',
183
205
  });
package/src/errors.ts CHANGED
@@ -16,6 +16,7 @@ export const AUTH_OWNED_ERROR_CODES = [
16
16
  'X_UNAUTHENTICATED',
17
17
  'X_SESSION_EXPIRED',
18
18
  'X_MFA_REQUIRED',
19
+ 'X_MFA_SECRET_INVALID',
19
20
  'X_OAUTH_STATE_INVALID',
20
21
  'X_OAUTH_EXCHANGE_FAILED',
21
22
  'X_OAUTH_TOKEN_INVALID',
@@ -60,6 +61,7 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
60
61
  X_UNAUTHENTICATED: 'no authenticated actor for this request',
61
62
  X_SESSION_EXPIRED: 'session passed its idle or absolute expiry',
62
63
  X_MFA_REQUIRED: 'a second factor is required before this session is usable',
64
+ X_MFA_SECRET_INVALID: 'the totp secret is not base32, so it carries no key to check a code with',
63
65
  X_OAUTH_STATE_INVALID: 'oauth state, nonce or pkce verifier did not match',
64
66
  X_OAUTH_EXCHANGE_FAILED: 'the oauth provider refused the exchange or returned no usable identity',
65
67
  X_OAUTH_TOKEN_INVALID: 'id token failed its signature, issuer, audience or expiry check',
@@ -174,6 +176,46 @@ export const mfaRequired = (userId: string): AuthError =>
174
176
  meta: { userId },
175
177
  });
176
178
 
179
+ /**
180
+ * A stored or imported TOTP secret the decoder cannot read. `base32Decode` answers zero bytes for
181
+ * any character outside the alphabet AND for `''`, and an HMAC keyed with zero bytes is a valid
182
+ * HMAC — so `totpCode` was handing out a six-digit code derived from no secret at all, one shared
183
+ * stream every malformed row in the table verified against. A code nobody had to know a secret to
184
+ * compute is not a second factor, so there is no code to hand back: this refuses instead.
185
+ *
186
+ * `verifyTotp` does NOT throw it. A login checking a broken row is the generic failure, exactly as
187
+ * `verifyAgainst` treats a stored hash Bun cannot read — a coded throw out of the verify path is a
188
+ * 500 where a credential refusal belongs, and it answers "this account's secret is malformed" to
189
+ * whoever asked. The secret itself never reaches `cause:` or `fix:`: it is a credential, and both
190
+ * are logged.
191
+ */
192
+ export const mfaSecretInvalid = (surface: string): AuthError =>
193
+ new AuthError({
194
+ code: 'X_MFA_SECRET_INVALID',
195
+ cause: `${surface} was given a totp secret that is not RFC 4648 base32, so it decodes to zero bytes`,
196
+ fix: 'issue a fresh one and store it: const { secret } = enrolTotp(auth, { account: user.email }) — an imported secret must be base32 (A-Z and 2-7, padding, spaces and dashes ignored) and decode to at least one byte',
197
+ });
198
+
199
+ /**
200
+ * `defineAuth({ mfa: { required: true } })`, refused where it is declared. The option read as a
201
+ * security guarantee — "this deployment requires a second factor" — and nothing in this package
202
+ * could make it true: `login()` and `signInWithOAuth()` branch on `user.mfaSecret` alone, so a
203
+ * user who never enrolled was handed a fully-privileged session under it, and `actorFromUser`
204
+ * strips privileges only for a user who HAS a secret, so there is no half-authenticated actor to
205
+ * hand them instead. Enforcing it inside `login()` would refuse exactly the people with nothing to
206
+ * offer, with no enrolment route shipped to send them to — a permanent lockout, not a second
207
+ * factor. So the declaration is refused at boot, exactly as `assertAuthLimiterPolicy` refuses a
208
+ * per-process limiter under a fleet-wide lockout: a guarantee this package cannot show holds is
209
+ * never assumed. `X_CONFIG_INVALID` is core's code, borrowed rather than re-declared.
210
+ */
211
+ export const mfaRequiredUnenforceable = (): AuthError =>
212
+ new AuthError({
213
+ code: 'X_CONFIG_INVALID',
214
+ cause:
215
+ 'defineAuth({ mfa: { required: true } }) declares a second factor this package does not enforce: login() mints a full session for any user whose mfaSecret is null',
216
+ fix: 'drop required from defineAuth({ mfa }) and gate it in your own sign-in handler, which is where the enrolment route lives: if (user.mfaSecret === null) send them to enrolTotp(auth, { account: user.email }) instead of createSession(auth.sessions, ...)',
217
+ });
218
+
177
219
  /**
178
220
  * The `fix:` quotes `oauthStartPath` rather than a hand-written path. That is not tidiness: this
179
221
  * line shipped naming `GET /auth/oauth/<provider>` while `@ultimat3/auth` mounted no route at all,
@@ -3,6 +3,7 @@
3
3
  // chances for one to drift from what `decodeIdToken` actually parses. Not part of the public
4
4
  // API — `index.ts` deliberately does not re-export it.
5
5
 
6
+ import type { IdTokenClaims } from './id-token';
6
7
  import { base64Url } from './tokens';
7
8
 
8
9
  /** `base64Url` takes bytes because every real secret is bytes; a JWT segment is text. */
@@ -12,5 +13,8 @@ export const base64UrlText = (value: string): string => base64Url(new TextEncode
12
13
  * Header, payload, and a signature that is not one. Signatures are never checked here — the
13
14
  * token is only ever read straight off the token endpoint — so a fixture needs no signer.
14
15
  */
15
- export const unsignedJwt = (claims: Readonly<Record<string, unknown>>): string =>
16
+ // The union, and not `Record<string, unknown>` alone: `IdTokenClaims` is an `interface`, so it has
17
+ // no implicit index signature and the one shape this fixture exists to serialise was the one shape
18
+ // it refused. The record arm stays for the malformed payloads `id-token.test.ts` builds by hand.
19
+ export const unsignedJwt = (claims: IdTokenClaims | Readonly<Record<string, unknown>>): string =>
16
20
  `${base64UrlText('{"alg":"RS256"}')}.${base64UrlText(JSON.stringify(claims))}.signature`;
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export type {
41
41
  export {
42
42
  AccountSchema,
43
43
  authenticate,
44
+ DEFAULT_MFA_ISSUER,
44
45
  defineAuth,
45
46
  login,
46
47
  logout,
@@ -72,6 +73,8 @@ export {
72
73
  forbidden,
73
74
  kdfOverloaded,
74
75
  mfaRequired,
76
+ mfaRequiredUnenforceable,
77
+ mfaSecretInvalid,
75
78
  oauthAccountNotLinked,
76
79
  oauthDenied,
77
80
  oauthExchangeFailed,
@@ -120,6 +123,7 @@ export {
120
123
  export { MemoryAdapter } from './memory-adapter';
121
124
  export type {
122
125
  EnrolTotpInput,
126
+ MemoryTotpReplayGuard,
123
127
  RecoveryCodeSet,
124
128
  TotpEnrolment,
125
129
  TotpReplayGuard,
@@ -130,6 +134,7 @@ export {
130
134
  base32Decode,
131
135
  base32Encode,
132
136
  createTotpReplayGuard,
137
+ DEFAULT_MAX_TOTP_SUBJECTS,
133
138
  enrolTotp,
134
139
  generateRecoveryCodes,
135
140
  generateTotpSecret,
package/src/mfa.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  // phishing page stays valid for the rest of its 30 seconds. Recovery codes are hashed at rest
4
4
  // and single-use, so a database dump is not a permanent MFA bypass.
5
5
 
6
+ import type { Auth } from './auth';
7
+ import { mfaSecretInvalid } from './errors';
6
8
  import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';
7
9
 
8
10
  const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
@@ -29,8 +31,16 @@ export function base32Encode(bytes: Uint8Array): string {
29
31
  }
30
32
 
31
33
  /**
32
- * Tolerant by design: padding, spaces and the dashes authenticator apps display are skipped,
33
- * and any other character fails the decode closed (empty output -> no code ever matches).
34
+ * Tolerant by design: padding, spaces and the dashes authenticator apps display are skipped, and
35
+ * any other character answers zero bytes.
36
+ *
37
+ * Zero bytes is NOT a decode that failed closed — this comment claimed it was, and it was the
38
+ * whole defect. An HMAC keyed with zero bytes is a perfectly valid HMAC, so `totpCode` derived a
39
+ * six-digit code from no secret at all and every unreadable secret in the table shared that one
40
+ * stream: a code an attacker computes without knowing anything verified against all of them.
41
+ * Zero bytes is therefore refused by both callers that need a key (`totpCode`, `enrolTotp`) and
42
+ * read as a non-verdict by `verifyTotp`. The decoder itself still answers rather than throwing,
43
+ * because "can this be read" is a question `enrolTotp` asks about a value it has not accepted yet.
34
44
  */
35
45
  export function base32Decode(value: string): Uint8Array {
36
46
  const bytes: number[] = [];
@@ -63,17 +73,32 @@ export interface TotpEnrolment {
63
73
  }
64
74
 
65
75
  export interface EnrolTotpInput {
66
- readonly issuer: string;
76
+ /**
77
+ * Omitted, the issuer is `auth.mfa.issuer` — one declaration, at `defineAuth`, so the product
78
+ * name an authenticator app shows is not restated at every enrolment. Named here only when one
79
+ * call needs a different one (a separate admin console entry, say).
80
+ */
81
+ readonly issuer?: string | undefined;
67
82
  readonly account: string;
68
83
  readonly secret?: string | undefined;
69
84
  }
70
85
 
71
- export function enrolTotp(input: EnrolTotpInput): TotpEnrolment {
86
+ /**
87
+ * Takes the `Auth` every other entry point in this package takes, and for the same reason: the
88
+ * issuer is configuration, and a pure function that could not read the configuration is what made
89
+ * `defineAuth({ mfa: { issuer } })` a string the framework wrote down and never read.
90
+ */
91
+ export function enrolTotp(auth: Auth, input: EnrolTotpInput): TotpEnrolment {
72
92
  const secret = input.secret ?? generateTotpSecret();
73
- const label = `${encodeURIComponent(input.issuer)}:${encodeURIComponent(input.account)}`;
93
+ // Defence in depth, on the one path that puts a caller's own bytes in front of the table: a
94
+ // secret nothing can ever derive a code from is refused before it is written, not after a user
95
+ // is locked out by it. A minted secret is readable by construction, so only an import gets here.
96
+ if (base32Decode(secret).length === 0) throw mfaSecretInvalid('enrolTotp');
97
+ const issuer = input.issuer ?? auth.mfa.issuer;
98
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(input.account)}`;
74
99
  const query = new URLSearchParams({
75
100
  secret,
76
- issuer: input.issuer,
101
+ issuer,
77
102
  algorithm: 'SHA1',
78
103
  digits: String(TOTP_DIGITS),
79
104
  period: String(TOTP_STEP_SECONDS),
@@ -103,6 +128,10 @@ function counterBytes(step: number): Uint8Array {
103
128
  /** HMAC-SHA1 + RFC 4226 dynamic truncation. SHA1 here is a spec requirement, not a choice. */
104
129
  export function totpCode(secret: string, step: number, digits: number = TOTP_DIGITS): string {
105
130
  const key = base32Decode(secret);
131
+ // A zero-length key is not a weak key, it is no key: the hasher below accepts it and answers a
132
+ // code every other unreadable secret answers too. There is no code an unreadable secret is
133
+ // entitled to, so this returns none.
134
+ if (key.length === 0) throw mfaSecretInvalid('totpCode');
106
135
  const mac = Uint8Array.from(
107
136
  new Bun.CryptoHasher('sha1', key).update(counterBytes(step)).digest(),
108
137
  );
@@ -131,6 +160,12 @@ export interface VerifyTotpInput {
131
160
  }
132
161
 
133
162
  export function verifyTotp(input: VerifyTotpInput): TotpVerification {
163
+ // A non-verdict, not a verdict and not a throw. It is the rule `verifyAgainst` (`password.ts`)
164
+ // follows for a stored hash Bun cannot read: a broken stored credential is the generic failure,
165
+ // because a coded throw out of a verify path is a 500 where a refusal belongs and it answers
166
+ // "this account's secret is malformed" to whoever asked. It also keeps `totpCode`'s refusal off
167
+ // the login path entirely — nothing below can reach it with a zero-length key.
168
+ if (base32Decode(input.secret).length === 0) return { ok: false, step: null };
134
169
  const drift = input.drift ?? TOTP_DRIFT_STEPS;
135
170
  const current = totpStep(input.at);
136
171
  const candidate = input.code.replaceAll(' ', '');
@@ -148,23 +183,106 @@ export interface TotpReplayGuard {
148
183
  remember(subject: string, step: number, at: Date): void;
149
184
  }
150
185
 
186
+ /** What `createTotpReplayGuard` returns: the interface, plus the bound it keeps, observable. */
187
+ export interface MemoryTotpReplayGuard extends TotpReplayGuard {
188
+ readonly size: number;
189
+ }
190
+
191
+ /**
192
+ * Hard bound on tracked subjects. One entry is one user who has completed a TOTP check inside the
193
+ * last drift window, so the natural cardinality is far below this — the cap is the backstop, the
194
+ * same one `DEFAULT_MAX_AUTH_LIMIT_KEYS` is for the limiter's table.
195
+ */
196
+ export const DEFAULT_MAX_TOTP_SUBJECTS = 10_000;
197
+
198
+ /** An idle guard still sweeps this often, so one burst's subjects do not sit until the next. */
199
+ const SWEEP_EVERY_STEPS = 2;
200
+
201
+ /**
202
+ * The cap arithmetic ran on the caller's number unchecked, and the two values a misread config
203
+ * hands you each defeated the bound in their own way: `Infinity` makes `used.size > cap` never
204
+ * true, so the table is exactly as unbounded as before it was capped; `NaN` makes EVERY comparison
205
+ * false, so `used.size <= evictTo` never stops the eviction loop and one sweep empties the table —
206
+ * including the subject who just authenticated, whose step is then replayable. Anything that is
207
+ * not a positive finite integer is a config the caller did not mean, so it takes the default
208
+ * rather than a bound derived from it. A fraction still floors: `2.5` is a caller who meant 2.
209
+ */
210
+ function boundedSubjects(maxSubjects: number): number {
211
+ if (!Number.isFinite(maxSubjects) || maxSubjects < 1) return DEFAULT_MAX_TOTP_SUBJECTS;
212
+ return Math.floor(maxSubjects);
213
+ }
214
+
215
+ /** The last step this subject has spent — how close their entry still is to the live window. */
216
+ const newestStep = (steps: ReadonlySet<number>): number => {
217
+ let newest = Number.NEGATIVE_INFINITY;
218
+ for (const step of steps) newest = Math.max(newest, step);
219
+ return newest;
220
+ };
221
+
151
222
  /**
152
223
  * In-memory by default because a single web process is the common case; a multi-process
153
224
  * deployment passes a Redis-backed guard with the same two methods. Steps older than the
154
225
  * drift window are dropped — nothing outside it can be replayed anyway.
226
+ *
227
+ * Bounded, because the subject map only ever grew: pruning happened inside one subject's `Set`
228
+ * and never revisited a subject who stopped signing in, so the table carried one permanent entry
229
+ * per user for the life of the process. Two rules keep it flat, and the ORDER is the guarantee —
230
+ * evicting a subject makes a step they have already spent replayable again, so it may never be
231
+ * the subject who just authenticated. A subject whose every step has fallen below the drift floor
232
+ * is *forgotten*, not evicted: `verifyTotp` only ever offers a step within ±drift of now, so that
233
+ * entry answers exactly as a missing one and dropping it changes no decision. Only if forgetting
234
+ * is not enough does the cap evict live state, furthest from the live window first — the shape
235
+ * `createAuthLimiter` evicts by, where a live lockout is the last bucket to go.
155
236
  */
156
- export function createTotpReplayGuard(drift: number = TOTP_DRIFT_STEPS): TotpReplayGuard {
237
+ export function createTotpReplayGuard(
238
+ drift: number = TOTP_DRIFT_STEPS,
239
+ maxSubjects: number = DEFAULT_MAX_TOTP_SUBJECTS,
240
+ ): MemoryTotpReplayGuard {
157
241
  const used = new Map<string, Set<number>>();
242
+ const cap = boundedSubjects(maxSubjects);
243
+ // Batched down to 90% of the cap so the sort below is paid once per 10% of it, not per check.
244
+ const evictTo = Math.max(1, Math.floor(cap * 0.9));
245
+ let lastSweepStep = Number.NEGATIVE_INFINITY;
246
+
247
+ const prune = (steps: Set<number>, floor: number): void => {
248
+ for (const known of steps) {
249
+ if (known < floor) steps.delete(known);
250
+ }
251
+ };
252
+
253
+ const sweep = (now: number, floor: number): void => {
254
+ lastSweepStep = now;
255
+ for (const [subject, steps] of used) {
256
+ prune(steps, floor);
257
+ if (steps.size === 0) used.delete(subject);
258
+ }
259
+ if (used.size <= cap) return;
260
+ // Map iteration is insertion order and `remember` re-files the subject it touches, so this
261
+ // sort — stable by specification — breaks a tie on the newest step by least recently seen.
262
+ // Both keys point the same way: the subject who just proved a code is the last one out.
263
+ const furthest = [...used.entries()].sort((a, b) => newestStep(a[1]) - newestStep(b[1]));
264
+ for (const [subject] of furthest) {
265
+ if (used.size <= evictTo) break;
266
+ used.delete(subject);
267
+ }
268
+ };
269
+
158
270
  return {
271
+ get size() {
272
+ return used.size;
273
+ },
159
274
  isUsed: (subject, step) => used.get(subject)?.has(step) === true,
160
275
  remember: (subject, step, at) => {
276
+ const now = totpStep(at);
277
+ const floor = now - drift;
161
278
  const steps = used.get(subject) ?? new Set<number>();
162
- const floor = totpStep(at) - drift;
163
- for (const known of steps) {
164
- if (known < floor) steps.delete(known);
165
- }
279
+ prune(steps, floor);
166
280
  steps.add(step);
281
+ // Deleted before it is set, so this subject moves to the back of the iteration order and
282
+ // that order is least-recently-remembered first. Nothing else observes it.
283
+ used.delete(subject);
167
284
  used.set(subject, steps);
285
+ if (used.size > cap || now - lastSweepStep >= SWEEP_EVERY_STEPS) sweep(now, floor);
168
286
  },
169
287
  };
170
288
  }
package/src/password.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // a migration. Verification always burns a full KDF even when the user does not exist —
4
4
  // otherwise response time answers "is this email registered?" for free.
5
5
 
6
- import { passwordWeak } from './errors';
6
+ import { AuthError, passwordWeak } from './errors';
7
7
  import { kdfGate } from './kdf-gate';
8
8
 
9
9
  export interface PasswordParams {
@@ -100,12 +100,51 @@ export function needsRehash(
100
100
  }
101
101
 
102
102
  export interface VerifyPasswordInput {
103
- /** `null` when no user matched. The KDF still runs, on a throwaway hash. */
103
+ /**
104
+ * `null` when no user matched. The KDF still runs, on a throwaway hash — and a stored hash Bun
105
+ * cannot read takes that same branch, so neither is separable from a wrong password.
106
+ */
104
107
  readonly hash: string | null;
105
108
  readonly password: string;
106
109
  readonly params?: PasswordParams | undefined;
107
110
  }
108
111
 
112
+ /** The KDF the happy path would have burnt, then the one failure shape. Never a cheap answer. */
113
+ async function burnAndFail(
114
+ password: string,
115
+ params: PasswordParams,
116
+ ): Promise<PasswordVerification> {
117
+ await hashPassword(password, params);
118
+ return FAILED;
119
+ }
120
+
121
+ /**
122
+ * `false` is a wrong password, `null` is a stored hash Bun cannot read at all.
123
+ *
124
+ * `Bun.password.verify` THROWS rather than answering on a hash it cannot parse — measured, bun
125
+ * 1.3.14: a Django `pbkdf2_sha256$...` row is `UnsupportedAlgorithm`, a truncated bcrypt string is
126
+ * `InvalidEncoding`. Letting that escape was two faults. A bare `Error` reached `login()`, so the
127
+ * caller answered 500 instead of the one credential failure; and it landed on exactly the rows
128
+ * that have not migrated off the legacy scheme, which makes "has this account been migrated" —
129
+ * and therefore "does this account exist" — readable from the outside. That is the enumeration
130
+ * oracle the whole file is built to close, on the one table where a foreign hash is normal.
131
+ *
132
+ * Supported-but-old is a different thing and stays a verdict: bcrypt verifies natively here and
133
+ * `needsRehash` flags it, which is the lever a legacy migration rewrites rows with.
134
+ *
135
+ * Nothing is logged. The algorithm of an unreadable hash is the oracle again, one layer down.
136
+ */
137
+ async function verifyAgainst(password: string, hash: string): Promise<boolean | null> {
138
+ try {
139
+ return await kdfGate().run(async () => await Bun.password.verify(password, hash));
140
+ } catch (error) {
141
+ // The gate shedding (`X_OVERLOADED`) is load, never a verdict on the credential: swallowing it
142
+ // would answer "wrong password" for a request this process refused to do the work for.
143
+ if (error instanceof AuthError) throw error;
144
+ return null;
145
+ }
146
+ }
147
+
109
148
  /**
110
149
  * Never short-circuits on a missing user: the `hashPassword` call in the `null` branch costs
111
150
  * the same order of magnitude as the verify in the happy branch, so the two are not separable
@@ -113,13 +152,14 @@ export interface VerifyPasswordInput {
113
152
  */
114
153
  export async function verifyPassword(input: VerifyPasswordInput): Promise<PasswordVerification> {
115
154
  const params = input.params ?? DEFAULT_PASSWORD_PARAMS;
116
- // Read into a local so the closure below narrows without a cast.
155
+ // Read into a local so the two branches below narrow it without a cast.
117
156
  const hash = input.hash;
118
- if (hash === null) {
119
- await hashPassword(input.password, params);
120
- return FAILED;
121
- }
122
- const ok = await kdfGate().run(async () => await Bun.password.verify(input.password, hash));
157
+ // `''` joins the no-user branch rather than reaching the KDF: an account with no password
158
+ // credential (oauth-only) answers `false` from Bun for free, and a failure that costs nothing
159
+ // is a stopwatch away from "this address exists but has never set a password".
160
+ if (hash === null || hash === '') return await burnAndFail(input.password, params);
161
+ const ok = await verifyAgainst(input.password, hash);
162
+ if (ok === null) return await burnAndFail(input.password, params);
123
163
  if (!ok) return FAILED;
124
164
  return { ok: true, needsRehash: needsRehash(hash, params) };
125
165
  }