@ultimat3/auth 3.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
@@ -210,6 +210,23 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
210
210
  `mfa.issuer` is the half that stayed, and it now has a reader: `enrolTotp(auth, { account })`
211
211
  takes the `Auth` every other entry point takes, so the product name an authenticator app shows
212
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.
213
230
  - **`createTotpReplayGuard`'s table is bounded, and the eviction ORDER is the guarantee.** It
214
231
  pruned steps inside one subject's `Set` and never revisited a subject who stopped signing in, so
215
232
  the map carried one permanent entry per user for the life of the process. Evicting a subject
package/README.md CHANGED
@@ -244,7 +244,20 @@ export function secondFactorHolds(userId: string, secret: string, code: string):
244
244
  | `createTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
245
245
  | `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
246
246
  | `redeemRecoveryCode(code, hashes)` | the **remaining** hashes, or `null`. Persisting that array is what makes a code single-use |
247
- | `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 })`.
248
261
 
249
262
  `TOTP_DIGITS` (6), `TOTP_STEP_SECONDS` (30) and `TOTP_DRIFT_STEPS` (±1 window) are exported so an
250
263
  app's own copy of the parameters cannot disagree with the verifier's.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "3.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": "3.0.0",
35
- "@ultimat3/db": "3.0.0",
36
- "@ultimat3/schema": "3.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/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,26 @@ 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
+
177
199
  /**
178
200
  * `defineAuth({ mfa: { required: true } })`, refused where it is declared. The option read as a
179
201
  * security guarantee — "this deployment requires a second factor" — and nothing in this package
@@ -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
@@ -74,6 +74,7 @@ export {
74
74
  kdfOverloaded,
75
75
  mfaRequired,
76
76
  mfaRequiredUnenforceable,
77
+ mfaSecretInvalid,
77
78
  oauthAccountNotLinked,
78
79
  oauthDenied,
79
80
  oauthExchangeFailed,
package/src/mfa.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // and single-use, so a database dump is not a permanent MFA bypass.
5
5
 
6
6
  import type { Auth } from './auth';
7
+ import { mfaSecretInvalid } from './errors';
7
8
  import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';
8
9
 
9
10
  const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
@@ -30,8 +31,16 @@ export function base32Encode(bytes: Uint8Array): string {
30
31
  }
31
32
 
32
33
  /**
33
- * Tolerant by design: padding, spaces and the dashes authenticator apps display are skipped,
34
- * 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.
35
44
  */
36
45
  export function base32Decode(value: string): Uint8Array {
37
46
  const bytes: number[] = [];
@@ -81,6 +90,10 @@ export interface EnrolTotpInput {
81
90
  */
82
91
  export function enrolTotp(auth: Auth, input: EnrolTotpInput): TotpEnrolment {
83
92
  const secret = input.secret ?? generateTotpSecret();
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');
84
97
  const issuer = input.issuer ?? auth.mfa.issuer;
85
98
  const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(input.account)}`;
86
99
  const query = new URLSearchParams({
@@ -115,6 +128,10 @@ function counterBytes(step: number): Uint8Array {
115
128
  /** HMAC-SHA1 + RFC 4226 dynamic truncation. SHA1 here is a spec requirement, not a choice. */
116
129
  export function totpCode(secret: string, step: number, digits: number = TOTP_DIGITS): string {
117
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');
118
135
  const mac = Uint8Array.from(
119
136
  new Bun.CryptoHasher('sha1', key).update(counterBytes(step)).digest(),
120
137
  );
@@ -143,6 +160,12 @@ export interface VerifyTotpInput {
143
160
  }
144
161
 
145
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 };
146
169
  const drift = input.drift ?? TOTP_DRIFT_STEPS;
147
170
  const current = totpStep(input.at);
148
171
  const candidate = input.code.replaceAll(' ', '');