toiljs 0.0.91 → 0.0.93

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.
@@ -137,6 +137,38 @@ function authDomain(ctx: RouteContext): string {
137
137
  return 'localhost';
138
138
  }
139
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
+
140
172
  /**
141
173
  * Whether this domain requires a confirmed email before login. A plain
142
174
  * (tenant-readable) env var so an app can opt in itself; the edge ALSO force-sets
@@ -265,15 +297,22 @@ function ackOk(): Response {
265
297
  * unknown user yields the SAME stable salt as a known one would -- no
266
298
  * enumeration oracle.
267
299
  */
268
- function deriveSalt(username: string): Uint8Array {
269
- 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);
270
302
  }
271
303
 
272
- /** The one-time-token lookup key: SHA-256 of the raw token bytes. Storing only
273
- * the hash means a leak of the token store yields nothing usable the raw
274
- * token lives only in the emailed link. */
275
- function tokenId(raw: Uint8Array): TokenId {
276
- 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));
277
316
  }
278
317
 
279
318
  /** Mint a fresh 32-byte token; returns the raw bytes (for the link) — the store
@@ -302,11 +341,14 @@ function randomCode(digits: i32): string {
302
341
  return s;
303
342
  }
304
343
 
305
- /** The stored 2FA verifier: SHA-256 of `username || 0x00 || code`. The username
306
- * is bound INTO the hash (a null separator, unambiguous), so a code is only ever
307
- * valid for the account it was issued to; the plaintext code is NEVER stored. */
308
- function twoFaCodeHash(username: string, code: string): Uint8Array {
309
- return crypto.sha256Text(username + '\x00' + code);
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);
310
352
  }
311
353
 
312
354
  /** Constant-time equality of two fixed 32-byte SHA-256 hashes (XOR-accumulate,
@@ -325,18 +367,26 @@ function isKnownTwoFaMethod(m: u8): bool {
325
367
  // >>> e.g. `|| m == TWOFA_TOTP || m == TWOFA_SMS` <<<
326
368
  }
327
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.
328
374
  @data
329
375
  class Username {
376
+ host: string = '';
330
377
  name: string = '';
331
- constructor(name: string = '') {
378
+ constructor(host: string = '', name: string = '') {
379
+ this.host = host;
332
380
  this.name = name;
333
381
  }
334
382
  }
335
383
 
336
384
  @data
337
385
  class EmailKey {
386
+ host: string = '';
338
387
  email: string = '';
339
- constructor(email: string = '') {
388
+ constructor(host: string = '', email: string = '') {
389
+ this.host = host;
340
390
  this.email = email;
341
391
  }
342
392
  }
@@ -367,8 +417,10 @@ class TokenRec {
367
417
 
368
418
  @data
369
419
  class ChallengeId {
420
+ host: string = '';
370
421
  cid: Uint8Array = new Uint8Array(0);
371
- constructor(cid: Uint8Array = new Uint8Array(0)) {
422
+ constructor(host: string = '', cid: Uint8Array = new Uint8Array(0)) {
423
+ this.host = host;
372
424
  this.cid = cid;
373
425
  }
374
426
  }
@@ -408,8 +460,10 @@ class Challenge {
408
460
  * confirm/reset TokenId, so a confirm/reset token can never resolve here. */
409
461
  @data
410
462
  class TwoFaId {
463
+ host: string = '';
411
464
  id: Uint8Array = new Uint8Array(0);
412
- constructor(id: Uint8Array = new Uint8Array(0)) {
465
+ constructor(host: string = '', id: Uint8Array = new Uint8Array(0)) {
466
+ this.host = host;
413
467
  this.id = id;
414
468
  }
415
469
  }
@@ -470,12 +524,13 @@ class Auth {
470
524
  const evaluated = AuthService.oprfEvaluate(username, blinded);
471
525
  if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
472
526
 
527
+ const h = realm(ctx);
473
528
  const w = new DataWriter();
474
529
  w.writeU8(ST_OK);
475
530
  w.writeU32(MEM_KIB);
476
531
  w.writeU32(ITERS);
477
532
  w.writeU32(PAR);
478
- w.writeBytes(deriveSalt(username));
533
+ w.writeBytes(deriveSalt(h, username));
479
534
  w.writeBytes(evaluated);
480
535
  return Response.bytes(w.toBytes());
481
536
  }
@@ -506,13 +561,17 @@ class Auth {
506
561
  const regMsg = AuthService.buildRegisterMessage(username, pk);
507
562
  if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
508
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
+
509
568
  // Distinguishable statuses (not the generic 401) so the UI can say
510
569
  // "username taken" / "email in use". Signup intentionally leaks existence
511
570
  // (a product choice, now gated behind the PoP above); reset never does.
512
- if (AuthDb.accounts.exists(new Username(username))) {
571
+ if (AuthDb.accounts.exists(new Username(h, username))) {
513
572
  return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
514
573
  }
515
- if (AuthDb.emails.exists(new EmailKey(email))) {
574
+ if (AuthDb.emails.exists(new EmailKey(h, email))) {
516
575
  return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
517
576
  }
518
577
 
@@ -521,26 +580,26 @@ class Auth {
521
580
  a.username = username;
522
581
  a.email = email;
523
582
  a.emailConfirmed = !mustConfirm; // auto-confirm when confirmation is off
524
- a.salt = deriveSalt(username);
583
+ a.salt = deriveSalt(h, username);
525
584
  a.publicKey = pk;
526
585
  a.memKiB = MEM_KIB;
527
586
  a.iterations = ITERS;
528
587
  a.parallelism = PAR;
529
588
  // create-if-absent: a racing duplicate registration loses here, not above.
530
- if (!AuthDb.accounts.create(new Username(username), a)) {
589
+ if (!AuthDb.accounts.create(new Username(h, username), a)) {
531
590
  return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
532
591
  }
533
- // Reserve the email -> username index (uniqueness for reset-by-email). A
534
- // racing duplicate email loses here: roll back the account we just created
535
- // so a lost email race can't orphan a username whose email index points at
536
- // someone else (which would also break reset-by-email for it).
537
- if (!AuthDb.emails.create(new EmailKey(email), new EmailOwner(username))) {
538
- 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));
539
598
  return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
540
599
  }
541
600
 
542
601
  if (mustConfirm) {
543
- this.issueConfirmation(ctx, username, email);
602
+ this.issueConfirmation(h, ctx, username, email);
544
603
  }
545
604
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
546
605
  }
@@ -555,15 +614,17 @@ class Auth {
555
614
  const r = new DataReader(ctx.request.body);
556
615
  const raw = r.readBytes();
557
616
  if (!r.ok || raw.length == 0) return fail();
558
- // Consume FIRST (a replayed/expired token still burns), then validate.
559
- 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));
560
621
  if (rec == null) return fail();
561
622
  if (nowSecs() >= rec.exp) return fail();
562
- const acct = AuthDb.accounts.get(new Username(rec.username));
623
+ const acct = AuthDb.accounts.get(new Username(h, rec.username));
563
624
  if (acct == null) return fail();
564
625
  if (!acct.emailConfirmed) {
565
626
  acct.emailConfirmed = true;
566
- AuthDb.accounts.patch(new Username(rec.username), acct);
627
+ AuthDb.accounts.patch(new Username(h, rec.username), acct);
567
628
  }
568
629
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
569
630
  }
@@ -577,11 +638,12 @@ class Auth {
577
638
  const r = new DataReader(ctx.request.body);
578
639
  const email = r.readString();
579
640
  if (!r.ok) return fail();
580
- const owner = AuthDb.emails.get(new EmailKey(email));
641
+ const h = realm(ctx);
642
+ const owner = AuthDb.emails.get(new EmailKey(h, email));
581
643
  if (owner != null) {
582
- const acct = AuthDb.accounts.get(new Username(owner.username));
644
+ const acct = AuthDb.accounts.get(new Username(h, owner.username));
583
645
  if (acct != null && !acct.emailConfirmed) {
584
- this.issueConfirmation(ctx, owner.username, email);
646
+ this.issueConfirmation(h, ctx, owner.username, email);
585
647
  }
586
648
  }
587
649
  return ackOk();
@@ -603,14 +665,16 @@ class Auth {
603
665
  const evaluated = AuthService.oprfEvaluate(username, blinded);
604
666
  if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
605
667
 
606
- const known = AuthDb.accounts.exists(new Username(username));
668
+ const h = realm(ctx);
669
+ const known = AuthDb.accounts.exists(new Username(h, username));
607
670
  const cid = randomBytes(16);
608
671
  const nonce = randomBytes(32);
609
672
  const iat = nowSecs();
610
673
  const exp = iat + CHALLENGE_TTL_SECS;
611
674
 
612
675
  // Persist only for a real account; the response is identical either way,
613
- // 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.
614
678
  if (known) {
615
679
  const c = new Challenge();
616
680
  c.cid = cid;
@@ -618,7 +682,7 @@ class Auth {
618
682
  c.nonce = nonce;
619
683
  c.iat = iat;
620
684
  c.exp = exp;
621
- AuthDb.challenges.create(new ChallengeId(cid), c);
685
+ AuthDb.challenges.create(new ChallengeId(h, cid), c);
622
686
  }
623
687
 
624
688
  const w = new DataWriter();
@@ -627,7 +691,7 @@ class Auth {
627
691
  w.writeU32(MEM_KIB);
628
692
  w.writeU32(ITERS);
629
693
  w.writeU32(PAR);
630
- w.writeBytes(deriveSalt(username));
694
+ w.writeBytes(deriveSalt(h, username));
631
695
  w.writeBytes(nonce);
632
696
  w.writeU64(iat);
633
697
  w.writeU64(exp);
@@ -646,15 +710,17 @@ class Auth {
646
710
  const ct = r.readBytes();
647
711
  const sig = r.readBytes();
648
712
  if (!r.ok) return fail();
713
+ const h = realm(ctx);
649
714
 
650
715
  // 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
651
- 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));
652
718
  if (ch == null) return fail();
653
719
  if (nowSecs() >= ch.exp) return fail();
654
720
 
655
721
  // 2. Rebuild the message from OUR stored values + the client's ct (and
656
722
  // the bound params + server key id), load the account key, verify.
657
- const acct = AuthDb.accounts.get(new Username(ch.username));
723
+ const acct = AuthDb.accounts.get(new Username(h, ch.username));
658
724
  if (acct == null) return fail();
659
725
  const message = AuthService.buildLoginMessage(
660
726
  ch.username,
@@ -700,6 +766,7 @@ class Auth {
700
766
  if (acct.twoFactorMethod != TWOFA_NONE) {
701
767
  const raw = randomBytes(16); // the twoFaId the client echoes to /2fa/verify
702
768
  const codeHash = this.twoFaDeliver(
769
+ h,
703
770
  acct.twoFactorMethod,
704
771
  ch.username,
705
772
  acct.email,
@@ -713,7 +780,7 @@ class Auth {
713
780
  c2.exp = nowSecs() + TWOFA_TTL_SECS;
714
781
  c2.attempts = 0;
715
782
  c2.targetMethod = TWOFA_NONE; // a LOGIN challenge changes no method on success
716
- AuthDb.twoFaLogins.create(new TwoFaId(raw), c2);
783
+ AuthDb.twoFaLogins.create(new TwoFaId(h, raw), c2);
717
784
 
718
785
  const w2 = new DataWriter();
719
786
  w2.writeU8(ST_TWOFA_REQUIRED);
@@ -750,20 +817,21 @@ class Auth {
750
817
  const r = new DataReader(ctx.request.body);
751
818
  const email = r.readString();
752
819
  if (!r.ok) return fail();
753
- const owner = AuthDb.emails.get(new EmailKey(email));
754
- 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))) {
755
823
  // Invalidate this user's previous reset token (bound to one outstanding).
756
- const prev = AuthDb.resetTokenOf.getDelete(new Username(owner.username));
824
+ const prev = AuthDb.resetTokenOf.getDelete(new Username(h, owner.username));
757
825
  if (prev != null) AuthDb.resetTokens.delete(prev);
758
826
  const raw = mintToken();
759
- const tid = tokenId(raw);
827
+ const tid = tokenId(h, raw); // realm-bound: unredeemable on another domain
760
828
  const rec = new TokenRec();
761
829
  rec.username = owner.username;
762
830
  rec.exp = nowSecs() + RESET_TTL_SECS;
763
831
  AuthDb.resetTokens.create(tid, rec);
764
832
  // If a concurrent reset request won the pointer index, roll back the
765
833
  // token we just minted so exactly one stays outstanding (no orphan).
766
- if (!AuthDb.resetTokenOf.create(new Username(owner.username), tid)) {
834
+ if (!AuthDb.resetTokenOf.create(new Username(h, owner.username), tid)) {
767
835
  AuthDb.resetTokens.delete(tid);
768
836
  }
769
837
  // Token in the URL FRAGMENT, not the query string: a fragment is never
@@ -789,7 +857,8 @@ class Auth {
789
857
  const raw = r.readBytes();
790
858
  const blinded = r.readBytes();
791
859
  if (!r.ok || raw.length == 0) return fail();
792
- const rec = AuthDb.resetTokens.get(tokenId(raw));
860
+ const h = realm(ctx);
861
+ const rec = AuthDb.resetTokens.get(tokenId(h, raw));
793
862
  if (rec == null) return fail();
794
863
  if (nowSecs() >= rec.exp) return fail();
795
864
  const evaluated = AuthService.oprfEvaluate(rec.username, blinded);
@@ -801,7 +870,7 @@ class Auth {
801
870
  w.writeU32(MEM_KIB);
802
871
  w.writeU32(ITERS);
803
872
  w.writeU32(PAR);
804
- w.writeBytes(deriveSalt(rec.username));
873
+ w.writeBytes(deriveSalt(h, rec.username));
805
874
  w.writeBytes(evaluated);
806
875
  return Response.bytes(w.toBytes());
807
876
  }
@@ -820,11 +889,13 @@ class Auth {
820
889
  const proof = r.readBytes();
821
890
  if (!r.ok || raw.length == 0) return fail();
822
891
  if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
823
- // Consume FIRST (single use), then validate.
824
- 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));
825
896
  if (rec == null) return fail();
826
897
  if (nowSecs() >= rec.exp) return fail();
827
- const acct = AuthDb.accounts.get(new Username(rec.username));
898
+ const acct = AuthDb.accounts.get(new Username(h, rec.username));
828
899
  if (acct == null) return fail();
829
900
 
830
901
  const regMsg = AuthService.buildRegisterMessage(rec.username, pk);
@@ -835,7 +906,7 @@ class Auth {
835
906
  // so confirm the account while we are here.
836
907
  acct.publicKey = pk;
837
908
  acct.emailConfirmed = true;
838
- AuthDb.accounts.patch(new Username(rec.username), acct);
909
+ AuthDb.accounts.patch(new Username(h, rec.username), acct);
839
910
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
840
911
  }
841
912
 
@@ -878,23 +949,25 @@ class Auth {
878
949
  const id = r.readBytes();
879
950
  const code = r.readString();
880
951
  if (!r.ok || id.length == 0) return fail();
952
+ const h = realm(ctx);
881
953
 
882
954
  // RACE-FREE attempt cap: count this guess against a host-side EXACT
883
955
  // limiter keyed on THIS challenge (the twoFaId), not the peer IP. Unlike
884
956
  // the `ch.attempts` get+patch below, this is atomic across all workers,
885
957
  // so a concurrent many-IP attacker cannot exceed TWOFA_MAX_ATTEMPTS
886
958
  // guesses against one code. Denied -> 429 (Retry-After); the challenge is
887
- // left for its TTL to reap. Keyed on the hex so the key is stable ASCII.
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).
888
961
  const rl = RateLimitService.guardKeyed(
889
962
  TWOFA_VERIFY_RL_ROUTE,
890
963
  TWOFA_RL_STRATEGY,
891
964
  TWOFA_MAX_ATTEMPTS as i32,
892
965
  TWOFA_TTL_SECS as i32,
893
- crypto.toHex(id),
966
+ h + '\x00' + crypto.toHex(id),
894
967
  );
895
968
  if (rl != null) return rl;
896
969
 
897
- const key = new TwoFaId(id);
970
+ const key = new TwoFaId(h, id);
898
971
  // PEEK (not consume): a wrong guess must NOT burn the challenge (a typo
899
972
  // would otherwise lock the user out) until the attempt cap is reached.
900
973
  const ch = AuthDb.twoFaLogins.get(key);
@@ -903,7 +976,7 @@ class Auth {
903
976
  AuthDb.twoFaLogins.delete(key); // burn an expired / exhausted challenge
904
977
  return fail();
905
978
  }
906
- if (!this.twoFaVerifyCode(ch.username, code, ch)) {
979
+ if (!this.twoFaVerifyCode(h, ch.username, code, ch)) {
907
980
  // Wrong code: count the attempt; delete the challenge once the cap is
908
981
  // hit so a burned-out challenge cannot be ground further.
909
982
  ch.attempts = ch.attempts + 1;
@@ -914,7 +987,7 @@ class Auth {
914
987
 
915
988
  // SUCCESS: consume-once, then mint the session EXACTLY like login/finish.
916
989
  AuthDb.twoFaLogins.getDelete(key);
917
- const acct = AuthDb.accounts.get(new Username(ch.username));
990
+ const acct = AuthDb.accounts.get(new Username(h, ch.username));
918
991
  if (acct == null) return fail();
919
992
  const domain = authDomain(ctx);
920
993
  const toilUserId = ToilUserId.derive(acct.publicKey, ch.username, domain).toBytes();
@@ -945,7 +1018,8 @@ class Auth {
945
1018
  if (!isKnownTwoFaMethod(targetMethod)) return fail();
946
1019
  const u = AuthService.getUser();
947
1020
  if (u == null) return fail();
948
- const acct = AuthDb.accounts.get(new Username(u.username));
1021
+ const h = realm(_ctx);
1022
+ const acct = AuthDb.accounts.get(new Username(h, u.username));
949
1023
  if (acct == null) return fail();
950
1024
 
951
1025
  // Disabling when 2FA is already off: nothing to prove; generic ST_OK.
@@ -959,6 +1033,7 @@ class Auth {
959
1033
  // hold the existing factor before it is removed).
960
1034
  const deliverMethod: u8 = targetMethod != TWOFA_NONE ? targetMethod : acct.twoFactorMethod;
961
1035
  const codeHash = this.twoFaDeliver(
1036
+ h,
962
1037
  deliverMethod,
963
1038
  u.username,
964
1039
  acct.email,
@@ -974,8 +1049,8 @@ class Auth {
974
1049
  c.attempts = 0;
975
1050
  c.targetMethod = targetMethod; // what to set account.twoFactorMethod to on success
976
1051
  // One outstanding setup challenge per user: overwrite any prior.
977
- AuthDb.twoFaSetup.delete(new Username(u.username));
978
- AuthDb.twoFaSetup.create(new Username(u.username), c);
1052
+ AuthDb.twoFaSetup.delete(new Username(h, u.username));
1053
+ AuthDb.twoFaSetup.create(new Username(h, u.username), c);
979
1054
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
980
1055
  }
981
1056
 
@@ -993,6 +1068,7 @@ class Auth {
993
1068
  if (!r.ok) return fail();
994
1069
  const u = AuthService.getUser();
995
1070
  if (u == null) return fail();
1071
+ const h = realm(_ctx);
996
1072
 
997
1073
  // RACE-FREE attempt cap for the setup challenge (same rationale as
998
1074
  // /2fa/verify): an atomic host-side limiter keyed on the session user, so
@@ -1003,18 +1079,18 @@ class Auth {
1003
1079
  TWOFA_RL_STRATEGY,
1004
1080
  TWOFA_MAX_ATTEMPTS as i32,
1005
1081
  TWOFA_TTL_SECS as i32,
1006
- u.username,
1082
+ h + '\x00' + u.username,
1007
1083
  );
1008
1084
  if (srl != null) return srl;
1009
1085
 
1010
- const key = new Username(u.username);
1086
+ const key = new Username(h, u.username);
1011
1087
  const ch = AuthDb.twoFaSetup.get(key);
1012
1088
  if (ch == null) return fail();
1013
1089
  if (nowSecs() >= ch.exp || ch.attempts >= TWOFA_MAX_ATTEMPTS) {
1014
1090
  AuthDb.twoFaSetup.delete(key);
1015
1091
  return fail();
1016
1092
  }
1017
- if (!this.twoFaVerifyCode(ch.username, code, ch)) {
1093
+ if (!this.twoFaVerifyCode(h, ch.username, code, ch)) {
1018
1094
  ch.attempts = ch.attempts + 1;
1019
1095
  if (ch.attempts >= TWOFA_MAX_ATTEMPTS) AuthDb.twoFaSetup.delete(key);
1020
1096
  else AuthDb.twoFaSetup.patch(key, ch);
@@ -1037,7 +1113,8 @@ class Auth {
1037
1113
  public twoFaStatus(_ctx: RouteContext): Response {
1038
1114
  const u = AuthService.getUser();
1039
1115
  if (u == null) return fail();
1040
- const acct = AuthDb.accounts.get(new Username(u.username));
1116
+ const h = realm(_ctx);
1117
+ const acct = AuthDb.accounts.get(new Username(h, u.username));
1041
1118
  if (acct == null) return fail();
1042
1119
  return Response.bytes(new DataWriter().writeU8(acct.twoFactorMethod).toBytes());
1043
1120
  }
@@ -1053,7 +1130,13 @@ class Auth {
1053
1130
  * and pairs with a `case TWOFA_TOTP` in {@link twoFaVerifyCode}. The
1054
1131
  * lifecycle and the login/setup wiring do NOT change. <<<
1055
1132
  */
1056
- private twoFaDeliver(method: u8, username: string, email: string, subject: string): Uint8Array {
1133
+ private twoFaDeliver(
1134
+ host: string,
1135
+ method: u8,
1136
+ username: string,
1137
+ email: string,
1138
+ subject: string,
1139
+ ): Uint8Array {
1057
1140
  switch (method) {
1058
1141
  case TWOFA_EMAIL: {
1059
1142
  const code = randomCode(TWOFA_DIGITS);
@@ -1070,7 +1153,7 @@ class Auth {
1070
1153
  // DETACHED (non-suspending) send: constant-time, no provider-RTT
1071
1154
  // parking (matches the confirm/reset anti-enumeration posture).
1072
1155
  EmailService.sendDetached(email, subject, text, '2fa', html);
1073
- return twoFaCodeHash(username, code);
1156
+ return twoFaCodeHash(host, username, code);
1074
1157
  }
1075
1158
  // >>> case TWOFA_TOTP: { return twoFaSecretBinding(username); } <<<
1076
1159
  default:
@@ -1086,11 +1169,16 @@ class Auth {
1086
1169
  * >>> ADD A METHOD HERE: a new `case TWOFA_TOTP:` runs a TOTP check against the
1087
1170
  * stored secret binding. <<<
1088
1171
  */
1089
- private twoFaVerifyCode(username: string, code: string, challenge: TwoFaChallenge): bool {
1172
+ private twoFaVerifyCode(
1173
+ host: string,
1174
+ username: string,
1175
+ code: string,
1176
+ challenge: TwoFaChallenge,
1177
+ ): bool {
1090
1178
  switch (challenge.method) {
1091
1179
  case TWOFA_EMAIL:
1092
1180
  // Constant-time compare of the two 32-byte SHA-256 hashes.
1093
- return ctEqual(twoFaCodeHash(username, code), challenge.codeHash);
1181
+ return ctEqual(twoFaCodeHash(host, username, code), challenge.codeHash);
1094
1182
  // >>> case TWOFA_TOTP: return totpVerify(challenge.codeHash, code); <<<
1095
1183
  default:
1096
1184
  return false;
@@ -1108,19 +1196,24 @@ class Auth {
1108
1196
  * constant time, equalizing both paths. (Residual: on the exists path the
1109
1197
  * token mint above still does a sub-ms ToilDB write the miss path skips; a
1110
1198
  * fully constant-time guarantee would also equalize that DB work.) */
1111
- 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 {
1112
1205
  // Invalidate this user's previous confirm token (bound to one outstanding).
1113
- const prev = AuthDb.confirmTokenOf.getDelete(new Username(username));
1206
+ const prev = AuthDb.confirmTokenOf.getDelete(new Username(host, username));
1114
1207
  if (prev != null) AuthDb.confirmTokens.delete(prev);
1115
1208
  const raw = mintToken();
1116
- const tid = tokenId(raw);
1209
+ const tid = tokenId(host, raw); // realm-bound token id (see tokenId)
1117
1210
  const rec = new TokenRec();
1118
1211
  rec.username = username;
1119
1212
  rec.exp = nowSecs() + CONFIRM_TTL_SECS;
1120
1213
  AuthDb.confirmTokens.create(tid, rec);
1121
1214
  // If a concurrent request won the pointer index, roll back the token we
1122
1215
  // just minted so exactly one stays outstanding (no orphan).
1123
- if (!AuthDb.confirmTokenOf.create(new Username(username), tid)) {
1216
+ if (!AuthDb.confirmTokenOf.create(new Username(host, username), tid)) {
1124
1217
  AuthDb.confirmTokens.delete(tid);
1125
1218
  }
1126
1219
  // Token in the URL FRAGMENT (see resetRequest): kept out of server/CDN
package/src/cli/index.ts CHANGED
@@ -144,7 +144,8 @@ function printHelp(): void {
144
144
  '',
145
145
  bold('Options'),
146
146
  cmd('--root <dir>', 'project root (default: current directory)'),
147
- cmd('--port <n>', 'dev server port'),
147
+ cmd('--port <n>', 'dev/start: listen port (or PORT env / client.port; default 3000)'),
148
+ cmd('--host <h>', 'dev/start: bind host, e.g. 0.0.0.0 (or HOST env / client.host; default 127.0.0.1)'),
148
149
  cmd('--threads <n>', 'start: production HTTP worker count'),
149
150
  cmd('-t, --template', 'create: app | minimal'),
150
151
  cmd('--style <name>', 'create/configure: css | sass | less | stylus'),
@@ -213,7 +214,7 @@ async function main(): Promise<void> {
213
214
  case 'dev':
214
215
  banner();
215
216
  process.stdout.write(dim(' starting dev server…') + '\n\n');
216
- await dev({ root: flags.root, port: flags.port });
217
+ await dev({ root: flags.root, port: flags.port, host: flags.host });
217
218
  break;
218
219
 
219
220
  case 'build':
@@ -240,10 +241,21 @@ async function main(): Promise<void> {
240
241
  host: flags.host,
241
242
  threads: flags.threads,
242
243
  });
244
+ // A wildcard bind (0.0.0.0/::) is not universally openable, so present a
245
+ // clickable loopback URL and separately flag that it is exposed.
246
+ const wildcard =
247
+ server.host === '0.0.0.0' || server.host === '::' || server.host === '';
248
+ const shownHost = wildcard ? 'localhost' : server.host;
249
+ const exposed =
250
+ server.host !== '127.0.0.1' && server.host !== 'localhost' && server.host !== '::1';
243
251
  process.stdout.write(
244
252
  accent(' ➜ ') +
245
- bold(`http://localhost:${String(server.port)}`) +
253
+ bold(`http://${shownHost}:${String(server.port)}`) +
246
254
  dim(` ws channel: ${server.wsPath}`) +
255
+ (exposed
256
+ ? '\n' +
257
+ dim(` bound to ${server.host}:${String(server.port)} (exposed beyond loopback)`)
258
+ : '') +
247
259
  '\n\n',
248
260
  );
249
261
  break;