@ultimat3/auth 3.0.0 → 4.1.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 +17 -0
- package/README.md +14 -1
- package/package.json +4 -4
- package/src/errors.ts +22 -0
- package/src/id-token-fixture.ts +5 -1
- package/src/index.ts +1 -0
- package/src/mfa.ts +25 -2
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
|
+
"version": "4.1.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": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "4.1.0",
|
|
35
|
+
"@ultimat3/db": "4.1.0",
|
|
36
|
+
"@ultimat3/schema": "4.1.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
|
package/src/id-token-fixture.ts
CHANGED
|
@@ -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
|
-
|
|
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
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
|
-
*
|
|
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(' ', '');
|