toiljs 0.0.90 → 0.0.92

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.
@@ -70,6 +70,44 @@ const ST_OK: u8 = 0;
70
70
  const ST_TAKEN: u8 = 1; // username already registered
71
71
  const ST_EMAIL_TAKEN: u8 = 2; // email already in use (register)
72
72
  const ST_UNCONFIRMED: u8 = 2; // email not confirmed (login) — distinct endpoint, reuses the value
73
+ // login/finish 2FA status: the credential verified but a SECOND factor is
74
+ // required; no session is minted (see loginFinish). Distinct from ST_OK(0) /
75
+ // ST_UNCONFIRMED(2) so the client can branch.
76
+ const ST_TWOFA_REQUIRED: u8 = 3;
77
+
78
+ // Two-factor METHOD enum (append-only; the value is stored in AuthAccount and in
79
+ // every challenge, so NEVER renumber). 0 = 2FA off. Adding a method is one new
80
+ // value here plus a deliver arm + a verify arm (see the `switch (method)` sites);
81
+ // the challenge lifecycle and the login wiring do NOT change.
82
+ const TWOFA_NONE: u8 = 0;
83
+ const TWOFA_EMAIL: u8 = 1;
84
+ // Future (reserved, not yet implemented): TWOFA_TOTP: u8 = 2; TWOFA_SMS: u8 = 3;
85
+
86
+ // 2FA challenge tuning. The code is single-use, TTL'd, and attempt-limited (all
87
+ // enforced against the stored challenge, independent of the per-IP @ratelimit).
88
+ const TWOFA_TTL_SECS: u64 = 300; // a login/setup code is valid 5 minutes
89
+ const TWOFA_MAX_ATTEMPTS: u32 = 5; // wrong-code guesses before the challenge dies
90
+ const TWOFA_DIGITS: i32 = 6; // digits in an emailed numeric code
91
+
92
+ // RACE-FREE per-challenge attempt cap. The `ch.attempts` counter below is a
93
+ // non-atomic get+patch across two host calls, so on the edge (a fresh wasm
94
+ // instance per request across 14 workers) N concurrent verifies for ONE
95
+ // challenge can each read attempts=0 and all guess before any patch lands -- a
96
+ // lost-update TOCTOU that lifts the intended 5-guess cap to the attacker's
97
+ // in-flight concurrency. The per-IP @ratelimit does NOT backstop a many-IP
98
+ // attacker. So we ALSO count each verify against a host-side EXACT limiter keyed
99
+ // on the CHALLENGE itself (twoFaId / setup username), never the peer IP: the
100
+ // count is atomic and shared across all workers, so total guesses per challenge
101
+ // are hard-capped regardless of source IP or request concurrency. `ch.attempts`
102
+ // stays as the primary (UX-friendly, burns-the-row) sequential path; this is the
103
+ // concurrency backstop. The routeIds sit just under i32::MAX so they can never
104
+ // collide with the `@ratelimit` decorator's monotonic-from-0 routeIds.
105
+ const TWOFA_VERIFY_RL_ROUTE: i32 = 2147483001;
106
+ const TWOFA_SETUP_RL_ROUTE: i32 = 2147483002;
107
+ // The host strategy tag for SlidingWindow (matches `RateLimit.SlidingWindow` and
108
+ // what the @ratelimit decorator lowers to). Used as a raw int because `RateLimit`
109
+ // is a type-only ambient enum with no runtime backing in a handler body.
110
+ const TWOFA_RL_STRATEGY: i32 = 1;
73
111
 
74
112
  // Resolved-and-cached per instance; the audience id (server config, never
75
113
  // client-echoed). Read lazily (not at module top level) so the env host import
@@ -99,6 +137,38 @@ function authDomain(ctx: RouteContext): string {
99
137
  return 'localhost';
100
138
  }
101
139
 
140
+ /**
141
+ * The per-request TENANT REALM that EVERY AuthDb key and one-time code/token is
142
+ * scoped to: the routed, NORMALIZED Host (lowercased, port-stripped, IPv6-bracket
143
+ * aware). DERIVED IDENTICALLY to the session cookie's tenant tag
144
+ * (`server/globals/auth.ts` `__sessionTenantTag`) so the DB data and the session
145
+ * agree on "which domain" a request belongs to.
146
+ *
147
+ * This is the HARD cross-domain boundary. The realm is folded into every physical
148
+ * key (`Username`/`EmailKey`/`TwoFaId`/`ChallengeId`) and every one-time verifier
149
+ * (`tokenId`/`twoFaCodeHash`/`deriveSalt`), so a confirm/reset/2FA artifact minted
150
+ * under domain A addresses a DIFFERENT row AND hashes to a DIFFERENT verifier under
151
+ * domain B: a single toildb tenant that fronts multiple domains can NOT let an auth
152
+ * code from one be redeemed on another. Mint-realm and redeem-realm must match, and
153
+ * both are this normalized Host, so a code is bound to the domain it was issued on.
154
+ */
155
+ function realm(ctx: RouteContext): string {
156
+ const raw = ctx.request.header('host');
157
+ if (raw == null) return '';
158
+ let h = raw.toLowerCase();
159
+ if (h.startsWith('[')) {
160
+ // IPv6 literal `[addr]`(:port): keep through the closing bracket, drop the
161
+ // port. Bracket-aware so `[::1]` is not sliced to `[:` (which would collapse
162
+ // distinct IPv6 tenants). Mirrors the edge strip_port + the session tag.
163
+ const close = h.indexOf(']');
164
+ if (close >= 0) h = h.slice(0, close + 1);
165
+ } else {
166
+ const colon = h.lastIndexOf(':');
167
+ if (colon > 0) h = h.slice(0, colon); // drop :port
168
+ }
169
+ return h;
170
+ }
171
+
102
172
  /**
103
173
  * Whether this domain requires a confirmed email before login. A plain
104
174
  * (tenant-readable) env var so an app can opt in itself; the edge ALSO force-sets
@@ -227,15 +297,22 @@ function ackOk(): Response {
227
297
  * unknown user yields the SAME stable salt as a known one would -- no
228
298
  * enumeration oracle.
229
299
  */
230
- function deriveSalt(username: string): Uint8Array {
231
- return crypto.sha256Text('toil-auth-salt-v1:' + username).slice(0, 16);
300
+ function deriveSalt(host: string, username: string): Uint8Array {
301
+ return crypto.sha256Text('toil-auth-salt-v1:' + host + '\x00' + username).slice(0, 16);
232
302
  }
233
303
 
234
- /** The one-time-token lookup key: SHA-256 of the raw token bytes. Storing only
235
- * the hash means a leak of the token store yields nothing usable the raw
236
- * token lives only in the emailed link. */
237
- function tokenId(raw: Uint8Array): TokenId {
238
- return new TokenId(crypto.sha256(raw));
304
+ /** The one-time-token lookup key: SHA-256 of `host || 0x00 || rawToken`. Storing
305
+ * only the hash means a leak of the token store yields nothing usable (the raw
306
+ * token lives only in the emailed link); folding the tenant realm IN means the
307
+ * same raw token addresses a DIFFERENT row per host, so a confirm/reset token
308
+ * minted on domain A can never be redeemed on domain B (its `tokenId` under B's
309
+ * realm is a different key that misses). */
310
+ function tokenId(host: string, raw: Uint8Array): TokenId {
311
+ const prefix = Uint8Array.wrap(String.UTF8.encode(host + '\x00'));
312
+ const buf = new Uint8Array(prefix.length + raw.length);
313
+ buf.set(prefix, 0);
314
+ buf.set(raw, prefix.length);
315
+ return new TokenId(crypto.sha256(buf));
239
316
  }
240
317
 
241
318
  /** Mint a fresh 32-byte token; returns the raw bytes (for the link) — the store
@@ -244,18 +321,72 @@ function mintToken(): Uint8Array {
244
321
  return randomBytes(32);
245
322
  }
246
323
 
324
+ /**
325
+ * A CSPRNG numeric code of `digits` decimal digits (leading zeros kept), using
326
+ * REJECTION SAMPLING so there is NO modulo bias: a byte in [250,256) is rejected
327
+ * (250 = the largest multiple of 10 that is <= 256), so every accepted byte maps
328
+ * uniformly onto 0-9. Each digit is drawn independently until accepted.
329
+ */
330
+ function randomCode(digits: i32): string {
331
+ const buf = new Uint8Array(1);
332
+ let s = '';
333
+ for (let i = 0; i < digits; i++) {
334
+ let b: i32 = 255;
335
+ do {
336
+ crypto.getRandomValues(buf);
337
+ b = <i32>buf[0];
338
+ } while (b >= 250); // reject the biased tail
339
+ s += (b % 10).toString();
340
+ }
341
+ return s;
342
+ }
343
+
344
+ /** The stored 2FA verifier: SHA-256 of `host || 0x00 || username || 0x00 || code`.
345
+ * The tenant realm AND the username are bound INTO the hash (null separators,
346
+ * unambiguous), so a code is only ever valid for the account it was issued to AND
347
+ * the domain it was issued on -- a code from domain A can never satisfy the same
348
+ * user's challenge on domain B, even if the row were reachable. The plaintext code
349
+ * is NEVER stored. */
350
+ function twoFaCodeHash(host: string, username: string, code: string): Uint8Array {
351
+ return crypto.sha256Text(host + '\x00' + username + '\x00' + code);
352
+ }
353
+
354
+ /** Constant-time equality of two fixed 32-byte SHA-256 hashes (XOR-accumulate,
355
+ * no early exit on the first differing byte). Mirrors the client `bytesEqual`. */
356
+ function ctEqual(a: Uint8Array, b: Uint8Array): bool {
357
+ if (a.length != b.length) return false;
358
+ let diff: i32 = 0;
359
+ for (let i = 0; i < a.length; i++) diff |= (<i32>a[i]) ^ (<i32>b[i]);
360
+ return diff == 0;
361
+ }
362
+
363
+ /** Whether `m` is a 2FA method this build understands (TWOFA_NONE or a supported
364
+ * method). ADD A METHOD: also accept its enum value here. */
365
+ function isKnownTwoFaMethod(m: u8): bool {
366
+ return m == TWOFA_NONE || m == TWOFA_EMAIL;
367
+ // >>> e.g. `|| m == TWOFA_TOTP || m == TWOFA_SMS` <<<
368
+ }
369
+
370
+ // Every lookup KEY carries the tenant `host` (realm) as its FIRST field, so the
371
+ // physical key includes the domain and the SAME logical name/email/id addresses a
372
+ // DIFFERENT row per host (cross-domain isolation, on top of toildb's per-tenant
373
+ // scoping). `host` is populated from `realm(ctx)` at every call site.
247
374
  @data
248
375
  class Username {
376
+ host: string = '';
249
377
  name: string = '';
250
- constructor(name: string = '') {
378
+ constructor(host: string = '', name: string = '') {
379
+ this.host = host;
251
380
  this.name = name;
252
381
  }
253
382
  }
254
383
 
255
384
  @data
256
385
  class EmailKey {
386
+ host: string = '';
257
387
  email: string = '';
258
- constructor(email: string = '') {
388
+ constructor(host: string = '', email: string = '') {
389
+ this.host = host;
259
390
  this.email = email;
260
391
  }
261
392
  }
@@ -286,8 +417,10 @@ class TokenRec {
286
417
 
287
418
  @data
288
419
  class ChallengeId {
420
+ host: string = '';
289
421
  cid: Uint8Array = new Uint8Array(0);
290
- constructor(cid: Uint8Array = new Uint8Array(0)) {
422
+ constructor(host: string = '', cid: Uint8Array = new Uint8Array(0)) {
423
+ this.host = host;
291
424
  this.cid = cid;
292
425
  }
293
426
  }
@@ -306,6 +439,11 @@ class AuthAccount {
306
439
  parallelism: u32 = 0;
307
440
  email: string = '';
308
441
  emailConfirmed: bool = false;
442
+ // APPENDED (append-only, forward-compatible like `email` above): the account's
443
+ // second-factor method (see the TWOFA_* enum). An old row decodes as 0 =
444
+ // TWOFA_NONE (2FA off), the safe default, so existing accounts stay logged in
445
+ // by password alone until they opt in via /auth/2fa/setup.
446
+ twoFactorMethod: u8 = 0;
309
447
  }
310
448
 
311
449
  @data
@@ -317,6 +455,37 @@ class Challenge {
317
455
  exp: u64 = 0;
318
456
  }
319
457
 
458
+ /** The lookup key for a login 2FA challenge: 16 random bytes minted per login.
459
+ * Lives in its OWN collection (twoFaLogins), a SEPARATE NAMESPACE from
460
+ * confirm/reset TokenId, so a confirm/reset token can never resolve here. */
461
+ @data
462
+ class TwoFaId {
463
+ host: string = '';
464
+ id: Uint8Array = new Uint8Array(0);
465
+ constructor(host: string = '', id: Uint8Array = new Uint8Array(0)) {
466
+ this.host = host;
467
+ this.id = id;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * A pending second-factor challenge (login OR setup). METHOD-AGNOSTIC lifecycle
473
+ * fields (username / exp / attempts) plus the per-method `codeHash`; `method` is
474
+ * which factor was delivered and `targetMethod` is used ONLY by the setup flow
475
+ * (what to set `account.twoFactorMethod` to on success). Login challenges leave
476
+ * `targetMethod` at 0. `codeHash = SHA-256(username || 0x00 || code)`; the
477
+ * plaintext code is never stored.
478
+ */
479
+ @data
480
+ class TwoFaChallenge {
481
+ username: string = '';
482
+ method: u8 = 0;
483
+ codeHash: Uint8Array = new Uint8Array(0);
484
+ exp: u64 = 0;
485
+ attempts: u32 = 0;
486
+ targetMethod: u8 = 0;
487
+ }
488
+
320
489
  @database
321
490
  class AuthDb {
322
491
  @collection static accounts: Documents<Username, AuthAccount>;
@@ -329,6 +498,14 @@ class AuthDb {
329
498
  // user per kind (O(users)), not one per request (unbounded storage-griefing).
330
499
  @collection static confirmTokenOf: Documents<Username, TokenId>;
331
500
  @collection static resetTokenOf: Documents<Username, TokenId>;
501
+ // SECOND-FACTOR challenges live in their OWN collections, a namespace fully
502
+ // disjoint from confirm/reset tokens (above). A reset/confirm token looked up
503
+ // here finds nothing (wrong collection); a 2FA code/id looked up in the token
504
+ // collections finds nothing. The separation is STRUCTURAL, not a purpose
505
+ // string. `twoFaLogins` is keyed by a random per-login id; `twoFaSetup` is
506
+ // keyed by username = one outstanding setup challenge per user.
507
+ @collection static twoFaLogins: Documents<TwoFaId, TwoFaChallenge>;
508
+ @collection static twoFaSetup: Documents<Username, TwoFaChallenge>;
332
509
  }
333
510
 
334
511
  @rest('auth')
@@ -347,12 +524,13 @@ class Auth {
347
524
  const evaluated = AuthService.oprfEvaluate(username, blinded);
348
525
  if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
349
526
 
527
+ const h = realm(ctx);
350
528
  const w = new DataWriter();
351
529
  w.writeU8(ST_OK);
352
530
  w.writeU32(MEM_KIB);
353
531
  w.writeU32(ITERS);
354
532
  w.writeU32(PAR);
355
- w.writeBytes(deriveSalt(username));
533
+ w.writeBytes(deriveSalt(h, username));
356
534
  w.writeBytes(evaluated);
357
535
  return Response.bytes(w.toBytes());
358
536
  }
@@ -383,13 +561,17 @@ class Auth {
383
561
  const regMsg = AuthService.buildRegisterMessage(username, pk);
384
562
  if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
385
563
 
564
+ // Every key below is scoped to this request's tenant realm: `bob` / an email
565
+ // on domain A is a DIFFERENT account + index entry from the same on domain B.
566
+ const h = realm(ctx);
567
+
386
568
  // Distinguishable statuses (not the generic 401) so the UI can say
387
569
  // "username taken" / "email in use". Signup intentionally leaks existence
388
570
  // (a product choice, now gated behind the PoP above); reset never does.
389
- if (AuthDb.accounts.exists(new Username(username))) {
571
+ if (AuthDb.accounts.exists(new Username(h, username))) {
390
572
  return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
391
573
  }
392
- if (AuthDb.emails.exists(new EmailKey(email))) {
574
+ if (AuthDb.emails.exists(new EmailKey(h, email))) {
393
575
  return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
394
576
  }
395
577
 
@@ -398,26 +580,26 @@ class Auth {
398
580
  a.username = username;
399
581
  a.email = email;
400
582
  a.emailConfirmed = !mustConfirm; // auto-confirm when confirmation is off
401
- a.salt = deriveSalt(username);
583
+ a.salt = deriveSalt(h, username);
402
584
  a.publicKey = pk;
403
585
  a.memKiB = MEM_KIB;
404
586
  a.iterations = ITERS;
405
587
  a.parallelism = PAR;
406
588
  // create-if-absent: a racing duplicate registration loses here, not above.
407
- if (!AuthDb.accounts.create(new Username(username), a)) {
589
+ if (!AuthDb.accounts.create(new Username(h, username), a)) {
408
590
  return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
409
591
  }
410
- // Reserve the email -> username index (uniqueness for reset-by-email). A
411
- // racing duplicate email loses here: roll back the account we just created
412
- // so a lost email race can't orphan a username whose email index points at
413
- // someone else (which would also break reset-by-email for it).
414
- if (!AuthDb.emails.create(new EmailKey(email), new EmailOwner(username))) {
415
- AuthDb.accounts.delete(new Username(username));
592
+ // Reserve the email -> username index (uniqueness for reset-by-email, scoped
593
+ // per host). A racing duplicate email loses here: roll back the account we
594
+ // just created so a lost email race can't orphan a username whose email index
595
+ // points at someone else (which would also break reset-by-email for it).
596
+ if (!AuthDb.emails.create(new EmailKey(h, email), new EmailOwner(username))) {
597
+ AuthDb.accounts.delete(new Username(h, username));
416
598
  return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
417
599
  }
418
600
 
419
601
  if (mustConfirm) {
420
- this.issueConfirmation(ctx, username, email);
602
+ this.issueConfirmation(h, ctx, username, email);
421
603
  }
422
604
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
423
605
  }
@@ -432,15 +614,17 @@ class Auth {
432
614
  const r = new DataReader(ctx.request.body);
433
615
  const raw = r.readBytes();
434
616
  if (!r.ok || raw.length == 0) return fail();
435
- // Consume FIRST (a replayed/expired token still burns), then validate.
436
- const rec = AuthDb.confirmTokens.getDelete(tokenId(raw));
617
+ const h = realm(ctx);
618
+ // Consume FIRST (a replayed/expired token still burns), then validate. The
619
+ // token id folds in `h`, so a confirm token minted on another domain misses.
620
+ const rec = AuthDb.confirmTokens.getDelete(tokenId(h, raw));
437
621
  if (rec == null) return fail();
438
622
  if (nowSecs() >= rec.exp) return fail();
439
- const acct = AuthDb.accounts.get(new Username(rec.username));
623
+ const acct = AuthDb.accounts.get(new Username(h, rec.username));
440
624
  if (acct == null) return fail();
441
625
  if (!acct.emailConfirmed) {
442
626
  acct.emailConfirmed = true;
443
- AuthDb.accounts.patch(new Username(rec.username), acct);
627
+ AuthDb.accounts.patch(new Username(h, rec.username), acct);
444
628
  }
445
629
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
446
630
  }
@@ -454,11 +638,12 @@ class Auth {
454
638
  const r = new DataReader(ctx.request.body);
455
639
  const email = r.readString();
456
640
  if (!r.ok) return fail();
457
- const owner = AuthDb.emails.get(new EmailKey(email));
641
+ const h = realm(ctx);
642
+ const owner = AuthDb.emails.get(new EmailKey(h, email));
458
643
  if (owner != null) {
459
- const acct = AuthDb.accounts.get(new Username(owner.username));
644
+ const acct = AuthDb.accounts.get(new Username(h, owner.username));
460
645
  if (acct != null && !acct.emailConfirmed) {
461
- this.issueConfirmation(ctx, owner.username, email);
646
+ this.issueConfirmation(h, ctx, owner.username, email);
462
647
  }
463
648
  }
464
649
  return ackOk();
@@ -480,14 +665,16 @@ class Auth {
480
665
  const evaluated = AuthService.oprfEvaluate(username, blinded);
481
666
  if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
482
667
 
483
- const known = AuthDb.accounts.exists(new Username(username));
668
+ const h = realm(ctx);
669
+ const known = AuthDb.accounts.exists(new Username(h, username));
484
670
  const cid = randomBytes(16);
485
671
  const nonce = randomBytes(32);
486
672
  const iat = nowSecs();
487
673
  const exp = iat + CHALLENGE_TTL_SECS;
488
674
 
489
675
  // Persist only for a real account; the response is identical either way,
490
- // and login/finish for an unknown user fails generically at consume.
676
+ // and login/finish for an unknown user fails generically at consume. The
677
+ // challenge key folds in `h`, so a challenge is bound to the login domain.
491
678
  if (known) {
492
679
  const c = new Challenge();
493
680
  c.cid = cid;
@@ -495,7 +682,7 @@ class Auth {
495
682
  c.nonce = nonce;
496
683
  c.iat = iat;
497
684
  c.exp = exp;
498
- AuthDb.challenges.create(new ChallengeId(cid), c);
685
+ AuthDb.challenges.create(new ChallengeId(h, cid), c);
499
686
  }
500
687
 
501
688
  const w = new DataWriter();
@@ -504,7 +691,7 @@ class Auth {
504
691
  w.writeU32(MEM_KIB);
505
692
  w.writeU32(ITERS);
506
693
  w.writeU32(PAR);
507
- w.writeBytes(deriveSalt(username));
694
+ w.writeBytes(deriveSalt(h, username));
508
695
  w.writeBytes(nonce);
509
696
  w.writeU64(iat);
510
697
  w.writeU64(exp);
@@ -523,15 +710,17 @@ class Auth {
523
710
  const ct = r.readBytes();
524
711
  const sig = r.readBytes();
525
712
  if (!r.ok) return fail();
713
+ const h = realm(ctx);
526
714
 
527
715
  // 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
528
- const ch = AuthDb.challenges.getDelete(new ChallengeId(cid));
716
+ // The challenge is realm-keyed, so one minted on another domain misses.
717
+ const ch = AuthDb.challenges.getDelete(new ChallengeId(h, cid));
529
718
  if (ch == null) return fail();
530
719
  if (nowSecs() >= ch.exp) return fail();
531
720
 
532
721
  // 2. Rebuild the message from OUR stored values + the client's ct (and
533
722
  // the bound params + server key id), load the account key, verify.
534
- const acct = AuthDb.accounts.get(new Username(ch.username));
723
+ const acct = AuthDb.accounts.get(new Username(h, ch.username));
535
724
  if (acct == null) return fail();
536
725
  const message = AuthService.buildLoginMessage(
537
726
  ch.username,
@@ -565,6 +754,41 @@ class Auth {
565
754
  const sessionKey = AuthService.deriveSessionKey(sharedSecret, transcriptHash);
566
755
  const confirm = AuthService.serverConfirmTag(sessionKey, transcriptHash);
567
756
 
757
+ // 3c. SECOND-FACTOR GATE. If this account has 2FA enabled, DO NOT mint a
758
+ // session here. Mint a login 2FA challenge (method-agnostic lifecycle)
759
+ // and deliver the code (per-method), then return ST_TWOFA_REQUIRED
760
+ // WITH the serverConfirm tag (so the client still completes mutual auth
761
+ // and knows the server is genuine BEFORE it prompts for a code) and NO
762
+ // cookie. The challenge is stored in `twoFaLogins` (its own namespace,
763
+ // never resetTokens/confirmTokens). This runs AFTER the signature
764
+ // verify above, so it is not an oracle, and the login `Challenge` was
765
+ // already consumed (getDelete) at the top of this handler.
766
+ if (acct.twoFactorMethod != TWOFA_NONE) {
767
+ const raw = randomBytes(16); // the twoFaId the client echoes to /2fa/verify
768
+ const codeHash = this.twoFaDeliver(
769
+ h,
770
+ acct.twoFactorMethod,
771
+ ch.username,
772
+ acct.email,
773
+ 'Your login code',
774
+ );
775
+ if (codeHash.length == 0) return fail(); // unsupported/misconfigured method
776
+ const c2 = new TwoFaChallenge();
777
+ c2.username = ch.username;
778
+ c2.method = acct.twoFactorMethod;
779
+ c2.codeHash = codeHash;
780
+ c2.exp = nowSecs() + TWOFA_TTL_SECS;
781
+ c2.attempts = 0;
782
+ c2.targetMethod = TWOFA_NONE; // a LOGIN challenge changes no method on success
783
+ AuthDb.twoFaLogins.create(new TwoFaId(h, raw), c2);
784
+
785
+ const w2 = new DataWriter();
786
+ w2.writeU8(ST_TWOFA_REQUIRED);
787
+ w2.writeBytes(raw);
788
+ w2.writeBytes(confirm); // mutual-auth tag: the client verifies the server first
789
+ return Response.bytes(w2.toBytes()); // NO Set-Cookie: no session until 2FA verifies
790
+ }
791
+
568
792
  // 4. Success: mint the session for whatever `@user` this program has (built-in or the app's own
569
793
  // extended one). `__toilEncodeAuthUser` is injected by `--authUser`: it constructs the `@user`
570
794
  // (app fields at their defaults), sets the reserved identity, and encodes it. The stable
@@ -593,20 +817,21 @@ class Auth {
593
817
  const r = new DataReader(ctx.request.body);
594
818
  const email = r.readString();
595
819
  if (!r.ok) return fail();
596
- const owner = AuthDb.emails.get(new EmailKey(email));
597
- if (owner != null && AuthDb.accounts.exists(new Username(owner.username))) {
820
+ const h = realm(ctx);
821
+ const owner = AuthDb.emails.get(new EmailKey(h, email));
822
+ if (owner != null && AuthDb.accounts.exists(new Username(h, owner.username))) {
598
823
  // Invalidate this user's previous reset token (bound to one outstanding).
599
- const prev = AuthDb.resetTokenOf.getDelete(new Username(owner.username));
824
+ const prev = AuthDb.resetTokenOf.getDelete(new Username(h, owner.username));
600
825
  if (prev != null) AuthDb.resetTokens.delete(prev);
601
826
  const raw = mintToken();
602
- const tid = tokenId(raw);
827
+ const tid = tokenId(h, raw); // realm-bound: unredeemable on another domain
603
828
  const rec = new TokenRec();
604
829
  rec.username = owner.username;
605
830
  rec.exp = nowSecs() + RESET_TTL_SECS;
606
831
  AuthDb.resetTokens.create(tid, rec);
607
832
  // If a concurrent reset request won the pointer index, roll back the
608
833
  // token we just minted so exactly one stays outstanding (no orphan).
609
- if (!AuthDb.resetTokenOf.create(new Username(owner.username), tid)) {
834
+ if (!AuthDb.resetTokenOf.create(new Username(h, owner.username), tid)) {
610
835
  AuthDb.resetTokens.delete(tid);
611
836
  }
612
837
  // Token in the URL FRAGMENT, not the query string: a fragment is never
@@ -632,7 +857,8 @@ class Auth {
632
857
  const raw = r.readBytes();
633
858
  const blinded = r.readBytes();
634
859
  if (!r.ok || raw.length == 0) return fail();
635
- const rec = AuthDb.resetTokens.get(tokenId(raw));
860
+ const h = realm(ctx);
861
+ const rec = AuthDb.resetTokens.get(tokenId(h, raw));
636
862
  if (rec == null) return fail();
637
863
  if (nowSecs() >= rec.exp) return fail();
638
864
  const evaluated = AuthService.oprfEvaluate(rec.username, blinded);
@@ -644,7 +870,7 @@ class Auth {
644
870
  w.writeU32(MEM_KIB);
645
871
  w.writeU32(ITERS);
646
872
  w.writeU32(PAR);
647
- w.writeBytes(deriveSalt(rec.username));
873
+ w.writeBytes(deriveSalt(h, rec.username));
648
874
  w.writeBytes(evaluated);
649
875
  return Response.bytes(w.toBytes());
650
876
  }
@@ -663,11 +889,13 @@ class Auth {
663
889
  const proof = r.readBytes();
664
890
  if (!r.ok || raw.length == 0) return fail();
665
891
  if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
666
- // Consume FIRST (single use), then validate.
667
- const rec = AuthDb.resetTokens.getDelete(tokenId(raw));
892
+ const h = realm(ctx);
893
+ // Consume FIRST (single use), then validate. Realm-bound token id: a reset
894
+ // token from another domain hashes to a different key and misses here.
895
+ const rec = AuthDb.resetTokens.getDelete(tokenId(h, raw));
668
896
  if (rec == null) return fail();
669
897
  if (nowSecs() >= rec.exp) return fail();
670
- const acct = AuthDb.accounts.get(new Username(rec.username));
898
+ const acct = AuthDb.accounts.get(new Username(h, rec.username));
671
899
  if (acct == null) return fail();
672
900
 
673
901
  const regMsg = AuthService.buildRegisterMessage(rec.username, pk);
@@ -678,7 +906,7 @@ class Auth {
678
906
  // so confirm the account while we are here.
679
907
  acct.publicKey = pk;
680
908
  acct.emailConfirmed = true;
681
- AuthDb.accounts.patch(new Username(rec.username), acct);
909
+ AuthDb.accounts.patch(new Username(h, rec.username), acct);
682
910
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
683
911
  }
684
912
 
@@ -707,6 +935,256 @@ class Auth {
707
935
  return resp;
708
936
  }
709
937
 
938
+ /** POST /auth/2fa/verify body: bytes(twoFaId) str(code)
939
+ * resp: u8(status) [+ bytes(userData)] + Set-Cookie on success.
940
+ * The SECOND factor of a 2FA login: consumes the login challenge minted by
941
+ * login/finish and, on success, mints the session (the FIRST point a cookie
942
+ * is set for a 2FA account). NOT @auth (there is no session yet). The
943
+ * challenge is looked up in `twoFaLogins` ONLY, so a reset/confirm token can
944
+ * never be presented here. Single-use, attempt-limited, TTL'd, rate-limited. */
945
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
946
+ @post('/2fa/verify')
947
+ public twoFaVerify(ctx: RouteContext): Response {
948
+ const r = new DataReader(ctx.request.body);
949
+ const id = r.readBytes();
950
+ const code = r.readString();
951
+ if (!r.ok || id.length == 0) return fail();
952
+ const h = realm(ctx);
953
+
954
+ // RACE-FREE attempt cap: count this guess against a host-side EXACT
955
+ // limiter keyed on THIS challenge (the twoFaId), not the peer IP. Unlike
956
+ // the `ch.attempts` get+patch below, this is atomic across all workers,
957
+ // so a concurrent many-IP attacker cannot exceed TWOFA_MAX_ATTEMPTS
958
+ // guesses against one code. Denied -> 429 (Retry-After); the challenge is
959
+ // left for its TTL to reap. Key = realm + twoFaId hex (the edge limiter is
960
+ // already per-host; the prefix keeps dev parity + isolates per domain).
961
+ const rl = RateLimitService.guardKeyed(
962
+ TWOFA_VERIFY_RL_ROUTE,
963
+ TWOFA_RL_STRATEGY,
964
+ TWOFA_MAX_ATTEMPTS as i32,
965
+ TWOFA_TTL_SECS as i32,
966
+ h + '\x00' + crypto.toHex(id),
967
+ );
968
+ if (rl != null) return rl;
969
+
970
+ const key = new TwoFaId(h, id);
971
+ // PEEK (not consume): a wrong guess must NOT burn the challenge (a typo
972
+ // would otherwise lock the user out) until the attempt cap is reached.
973
+ const ch = AuthDb.twoFaLogins.get(key);
974
+ if (ch == null) return fail();
975
+ if (nowSecs() >= ch.exp || ch.attempts >= TWOFA_MAX_ATTEMPTS) {
976
+ AuthDb.twoFaLogins.delete(key); // burn an expired / exhausted challenge
977
+ return fail();
978
+ }
979
+ if (!this.twoFaVerifyCode(h, ch.username, code, ch)) {
980
+ // Wrong code: count the attempt; delete the challenge once the cap is
981
+ // hit so a burned-out challenge cannot be ground further.
982
+ ch.attempts = ch.attempts + 1;
983
+ if (ch.attempts >= TWOFA_MAX_ATTEMPTS) AuthDb.twoFaLogins.delete(key);
984
+ else AuthDb.twoFaLogins.patch(key, ch);
985
+ return fail();
986
+ }
987
+
988
+ // SUCCESS: consume-once, then mint the session EXACTLY like login/finish.
989
+ AuthDb.twoFaLogins.getDelete(key);
990
+ const acct = AuthDb.accounts.get(new Username(h, ch.username));
991
+ if (acct == null) return fail();
992
+ const domain = authDomain(ctx);
993
+ const toilUserId = ToilUserId.derive(acct.publicKey, ch.username, domain).toBytes();
994
+ const userData = __toilEncodeAuthUser(toilUserId, ch.username);
995
+ const w = new DataWriter();
996
+ w.writeU8(ST_OK);
997
+ w.writeBytes(userData);
998
+ const resp = Response.bytes(w.toBytes());
999
+ resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
1000
+ resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
1001
+ return resp;
1002
+ }
1003
+
1004
+ /** POST /auth/2fa/setup (@auth) body: u8(targetMethod) resp: u8(ST_OK)
1005
+ * Begin enabling or disabling 2FA for the SESSION user. Delivers a proof code
1006
+ * and stores a setup challenge in `twoFaSetup` (keyed by username = one
1007
+ * outstanding per user); /auth/2fa/setup/verify confirms it. ENABLE proves
1008
+ * control of the NEW method (deliver via targetMethod). DISABLE proves control
1009
+ * of the CURRENT method (deliver via account.twoFactorMethod) before the
1010
+ * factor is removed - anti-hijack. Always a generic ST_OK. */
1011
+ @auth
1012
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
1013
+ @post('/2fa/setup')
1014
+ public twoFaSetup(_ctx: RouteContext): Response {
1015
+ const r = new DataReader(_ctx.request.body);
1016
+ const targetMethod = r.readU8();
1017
+ if (!r.ok) return fail();
1018
+ if (!isKnownTwoFaMethod(targetMethod)) return fail();
1019
+ const u = AuthService.getUser();
1020
+ if (u == null) return fail();
1021
+ const h = realm(_ctx);
1022
+ const acct = AuthDb.accounts.get(new Username(h, u.username));
1023
+ if (acct == null) return fail();
1024
+
1025
+ // Disabling when 2FA is already off: nothing to prove; generic ST_OK.
1026
+ if (targetMethod == TWOFA_NONE && acct.twoFactorMethod == TWOFA_NONE) {
1027
+ return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
1028
+ }
1029
+
1030
+ // Which method delivers the PROOF code:
1031
+ // ENABLE (targetMethod != NONE): the NEW method (prove you control it).
1032
+ // DISABLE (targetMethod == NONE): the CURRENT method (prove you still
1033
+ // hold the existing factor before it is removed).
1034
+ const deliverMethod: u8 = targetMethod != TWOFA_NONE ? targetMethod : acct.twoFactorMethod;
1035
+ const codeHash = this.twoFaDeliver(
1036
+ h,
1037
+ deliverMethod,
1038
+ u.username,
1039
+ acct.email,
1040
+ 'Your verification code',
1041
+ );
1042
+ if (codeHash.length == 0) return fail(); // unsupported deliver method
1043
+
1044
+ const c = new TwoFaChallenge();
1045
+ c.username = u.username;
1046
+ c.method = deliverMethod;
1047
+ c.codeHash = codeHash;
1048
+ c.exp = nowSecs() + TWOFA_TTL_SECS;
1049
+ c.attempts = 0;
1050
+ c.targetMethod = targetMethod; // what to set account.twoFactorMethod to on success
1051
+ // One outstanding setup challenge per user: overwrite any prior.
1052
+ AuthDb.twoFaSetup.delete(new Username(h, u.username));
1053
+ AuthDb.twoFaSetup.create(new Username(h, u.username), c);
1054
+ return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
1055
+ }
1056
+
1057
+ /** POST /auth/2fa/setup/verify (@auth) body: str(code) resp: u8(ST_OK)
1058
+ * Confirms a pending /auth/2fa/setup challenge for the SESSION user: on a
1059
+ * correct code, sets account.twoFactorMethod = challenge.targetMethod (enable
1060
+ * OR disable) and consumes the challenge. Same single-use + attempt-limit +
1061
+ * TTL as /auth/2fa/verify. */
1062
+ @auth
1063
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
1064
+ @post('/2fa/setup/verify')
1065
+ public twoFaSetupVerify(_ctx: RouteContext): Response {
1066
+ const r = new DataReader(_ctx.request.body);
1067
+ const code = r.readString();
1068
+ if (!r.ok) return fail();
1069
+ const u = AuthService.getUser();
1070
+ if (u == null) return fail();
1071
+ const h = realm(_ctx);
1072
+
1073
+ // RACE-FREE attempt cap for the setup challenge (same rationale as
1074
+ // /2fa/verify): an atomic host-side limiter keyed on the session user, so
1075
+ // the `ch.attempts` get+patch below cannot be out-raced by concurrent
1076
+ // submissions. @auth-gated, so lower risk than login, but fixed alike.
1077
+ const srl = RateLimitService.guardKeyed(
1078
+ TWOFA_SETUP_RL_ROUTE,
1079
+ TWOFA_RL_STRATEGY,
1080
+ TWOFA_MAX_ATTEMPTS as i32,
1081
+ TWOFA_TTL_SECS as i32,
1082
+ h + '\x00' + u.username,
1083
+ );
1084
+ if (srl != null) return srl;
1085
+
1086
+ const key = new Username(h, u.username);
1087
+ const ch = AuthDb.twoFaSetup.get(key);
1088
+ if (ch == null) return fail();
1089
+ if (nowSecs() >= ch.exp || ch.attempts >= TWOFA_MAX_ATTEMPTS) {
1090
+ AuthDb.twoFaSetup.delete(key);
1091
+ return fail();
1092
+ }
1093
+ if (!this.twoFaVerifyCode(h, ch.username, code, ch)) {
1094
+ ch.attempts = ch.attempts + 1;
1095
+ if (ch.attempts >= TWOFA_MAX_ATTEMPTS) AuthDb.twoFaSetup.delete(key);
1096
+ else AuthDb.twoFaSetup.patch(key, ch);
1097
+ return fail();
1098
+ }
1099
+
1100
+ // SUCCESS: consume-once + apply the method change.
1101
+ AuthDb.twoFaSetup.getDelete(key);
1102
+ const acct = AuthDb.accounts.get(key);
1103
+ if (acct == null) return fail();
1104
+ acct.twoFactorMethod = ch.targetMethod;
1105
+ AuthDb.accounts.patch(key, acct);
1106
+ return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
1107
+ }
1108
+
1109
+ /** GET /auth/2fa/status (@auth) resp: u8(method) (0 = off, 1 = email, ...)
1110
+ * The session user's current 2FA method, so the UI can show state. */
1111
+ @auth
1112
+ @get('/2fa/status')
1113
+ public twoFaStatus(_ctx: RouteContext): Response {
1114
+ const u = AuthService.getUser();
1115
+ if (u == null) return fail();
1116
+ const h = realm(_ctx);
1117
+ const acct = AuthDb.accounts.get(new Username(h, u.username));
1118
+ if (acct == null) return fail();
1119
+ return Response.bytes(new DataWriter().writeU8(acct.twoFactorMethod).toBytes());
1120
+ }
1121
+
1122
+ /**
1123
+ * PER-METHOD 2FA DELIVERY. Generates and delivers a fresh code for `method`
1124
+ * and returns the codeHash to persist for the later verify; returns an EMPTY
1125
+ * array for an unsupported method (the caller fails closed). The plaintext
1126
+ * code never leaves this method.
1127
+ *
1128
+ * >>> ADD A METHOD HERE: a new `case TWOFA_TOTP:` returns the codeHash/secret
1129
+ * binding WITHOUT emailing (the code is the authenticator's own output),
1130
+ * and pairs with a `case TWOFA_TOTP` in {@link twoFaVerifyCode}. The
1131
+ * lifecycle and the login/setup wiring do NOT change. <<<
1132
+ */
1133
+ private twoFaDeliver(
1134
+ host: string,
1135
+ method: u8,
1136
+ username: string,
1137
+ email: string,
1138
+ subject: string,
1139
+ ): Uint8Array {
1140
+ switch (method) {
1141
+ case TWOFA_EMAIL: {
1142
+ const code = randomCode(TWOFA_DIGITS);
1143
+ const text =
1144
+ 'Your verification code is ' +
1145
+ code +
1146
+ '.\nIt expires in a few minutes. If you did not request it, ignore this email.\n';
1147
+ const html =
1148
+ '<p>Your verification code is:</p>' +
1149
+ '<p style="font-size:28px;font-weight:bold;letter-spacing:4px">' +
1150
+ code +
1151
+ '</p>' +
1152
+ '<p>It expires in a few minutes. If you did not request it, ignore this email.</p>';
1153
+ // DETACHED (non-suspending) send: constant-time, no provider-RTT
1154
+ // parking (matches the confirm/reset anti-enumeration posture).
1155
+ EmailService.sendDetached(email, subject, text, '2fa', html);
1156
+ return twoFaCodeHash(host, username, code);
1157
+ }
1158
+ // >>> case TWOFA_TOTP: { return twoFaSecretBinding(username); } <<<
1159
+ default:
1160
+ return new Uint8Array(0);
1161
+ }
1162
+ }
1163
+
1164
+ /**
1165
+ * PER-METHOD 2FA VERIFICATION: `true` iff `code` satisfies `challenge` for its
1166
+ * method. The method-agnostic lifecycle (peek / attempt-limit / TTL / consume)
1167
+ * lives in the endpoints; only the CODE CHECK is per-method here.
1168
+ *
1169
+ * >>> ADD A METHOD HERE: a new `case TWOFA_TOTP:` runs a TOTP check against the
1170
+ * stored secret binding. <<<
1171
+ */
1172
+ private twoFaVerifyCode(
1173
+ host: string,
1174
+ username: string,
1175
+ code: string,
1176
+ challenge: TwoFaChallenge,
1177
+ ): bool {
1178
+ switch (challenge.method) {
1179
+ case TWOFA_EMAIL:
1180
+ // Constant-time compare of the two 32-byte SHA-256 hashes.
1181
+ return ctEqual(twoFaCodeHash(host, username, code), challenge.codeHash);
1182
+ // >>> case TWOFA_TOTP: return totpVerify(challenge.codeHash, code); <<<
1183
+ default:
1184
+ return false;
1185
+ }
1186
+ }
1187
+
710
1188
  /** Mint a confirm token for `username`/`email` and email the confirm link.
711
1189
  * Best-effort: a failed send is not fatal (the user can resend).
712
1190
  *
@@ -718,19 +1196,24 @@ class Auth {
718
1196
  * constant time, equalizing both paths. (Residual: on the exists path the
719
1197
  * token mint above still does a sub-ms ToilDB write the miss path skips; a
720
1198
  * fully constant-time guarantee would also equalize that DB work.) */
721
- private issueConfirmation(ctx: RouteContext, username: string, email: string): void {
1199
+ private issueConfirmation(
1200
+ host: string,
1201
+ ctx: RouteContext,
1202
+ username: string,
1203
+ email: string,
1204
+ ): void {
722
1205
  // Invalidate this user's previous confirm token (bound to one outstanding).
723
- const prev = AuthDb.confirmTokenOf.getDelete(new Username(username));
1206
+ const prev = AuthDb.confirmTokenOf.getDelete(new Username(host, username));
724
1207
  if (prev != null) AuthDb.confirmTokens.delete(prev);
725
1208
  const raw = mintToken();
726
- const tid = tokenId(raw);
1209
+ const tid = tokenId(host, raw); // realm-bound token id (see tokenId)
727
1210
  const rec = new TokenRec();
728
1211
  rec.username = username;
729
1212
  rec.exp = nowSecs() + CONFIRM_TTL_SECS;
730
1213
  AuthDb.confirmTokens.create(tid, rec);
731
1214
  // If a concurrent request won the pointer index, roll back the token we
732
1215
  // just minted so exactly one stays outstanding (no orphan).
733
- if (!AuthDb.confirmTokenOf.create(new Username(username), tid)) {
1216
+ if (!AuthDb.confirmTokenOf.create(new Username(host, username), tid)) {
734
1217
  AuthDb.confirmTokens.delete(tid);
735
1218
  }
736
1219
  // Token in the URL FRAGMENT (see resetRequest): kept out of server/CDN