toiljs 0.0.91 → 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.
- package/CHANGELOG.md +5 -0
- package/package.json +1 -1
- package/server/auth/AuthController.ts +166 -73
- package/test/pqauth-email.test.ts +85 -3
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
273
|
-
* the hash means a leak of the token store yields nothing usable
|
|
274
|
-
* token lives only in the emailed link
|
|
275
|
-
|
|
276
|
-
|
|
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`.
|
|
306
|
-
*
|
|
307
|
-
* valid for the account it was issued to
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
534
|
-
// racing duplicate email loses here: roll back the account we
|
|
535
|
-
// so a lost email race can't orphan a username whose email index
|
|
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
|
-
|
|
559
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
754
|
-
|
|
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
|
|
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
|
-
|
|
824
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
@@ -40,17 +40,20 @@ function loadModule(): WasmServerModule {
|
|
|
40
40
|
return m;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/** Route the client's `fetch(path, {body})` into the dev wasm dispatcher (with a cookie jar).
|
|
43
|
+
/** Route the client's `fetch(path, {body})` into the dev wasm dispatcher (with a cookie jar).
|
|
44
|
+
* The Host header the client's requests carry is settable (`setHost`) so a test can drive
|
|
45
|
+
* the same client against DIFFERENT tenant domains and prove per-host auth isolation. */
|
|
44
46
|
function installFetchShim(m: WasmServerModule): () => void {
|
|
45
47
|
const original = globalThis.fetch;
|
|
46
48
|
const jar = new Map<string, string>();
|
|
49
|
+
let host = 'localhost:3000';
|
|
47
50
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
48
51
|
const url = typeof input === 'string' ? input : input.toString();
|
|
49
52
|
const pathname = new URL(url, 'http://localhost').pathname;
|
|
50
53
|
const bodyBytes =
|
|
51
54
|
init?.body == null ? new Uint8Array(0) : new Uint8Array(init.body as ArrayBuffer);
|
|
52
55
|
const headers: [string, string][] = [
|
|
53
|
-
['host',
|
|
56
|
+
['host', host],
|
|
54
57
|
['content-type', 'application/octet-stream'],
|
|
55
58
|
];
|
|
56
59
|
if (jar.size > 0)
|
|
@@ -80,6 +83,9 @@ function installFetchShim(m: WasmServerModule): () => void {
|
|
|
80
83
|
globalThis.fetch = original;
|
|
81
84
|
},
|
|
82
85
|
jar,
|
|
86
|
+
setHost: (h: string) => {
|
|
87
|
+
host = h;
|
|
88
|
+
},
|
|
83
89
|
};
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -136,6 +142,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
136
142
|
let restoreFetch: () => void;
|
|
137
143
|
let mod: WasmServerModule;
|
|
138
144
|
let jar: Map<string, string>;
|
|
145
|
+
let setHost: (h: string) => void;
|
|
139
146
|
|
|
140
147
|
beforeEach(() => {
|
|
141
148
|
__resetDbForTests();
|
|
@@ -148,6 +155,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
148
155
|
const shim = installFetchShim(mod);
|
|
149
156
|
restoreFetch = shim.restore;
|
|
150
157
|
jar = shim.jar;
|
|
158
|
+
setHost = shim.setHost;
|
|
151
159
|
});
|
|
152
160
|
afterEach(() => {
|
|
153
161
|
restoreFetch();
|
|
@@ -179,10 +187,13 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
179
187
|
it(
|
|
180
188
|
'reset link is NOT poisonable via a crafted Host header (#6 host-header reset-poisoning)',
|
|
181
189
|
async () => {
|
|
190
|
+
// grace lives on the victim tenant (realm = normalized Host "victim.com").
|
|
191
|
+
setHost('victim.com');
|
|
182
192
|
await Auth.register('grace', 'pw-grace-correct', 'grace@x.com');
|
|
183
193
|
__clearSentEmails();
|
|
184
194
|
// Attacker triggers reset/request with a Host that routes to the victim
|
|
185
|
-
// (the edge strip_port -> "victim.com"
|
|
195
|
+
// (the edge strip_port -> "victim.com", which is grace's realm, so the
|
|
196
|
+
// request DOES resolve her account) but, dropped raw into a link,
|
|
186
197
|
// reparents the URL authority to attacker.com.
|
|
187
198
|
const body = new DataWriter().writeString('grace@x.com').toBytes();
|
|
188
199
|
const r = mod.dispatch({
|
|
@@ -634,4 +645,75 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
634
645
|
},
|
|
635
646
|
120_000,
|
|
636
647
|
);
|
|
648
|
+
|
|
649
|
+
it(
|
|
650
|
+
'(g) per-domain isolation: a confirm/reset/2FA code minted on domain A is USELESS on domain B',
|
|
651
|
+
async () => {
|
|
652
|
+
const A = 'a.example.com';
|
|
653
|
+
const B = 'b.example.com';
|
|
654
|
+
|
|
655
|
+
// ---- confirm token: minted on A, unredeemable on B ----
|
|
656
|
+
setConfirmation(true);
|
|
657
|
+
setHost(A);
|
|
658
|
+
await Auth.register('mia', 'mia-pw-strong', 'mia@x.com'); // realm A + confirm token emailed
|
|
659
|
+
const confirmTok = tokenFromEmail('confirm', 'mia@x.com');
|
|
660
|
+
|
|
661
|
+
setHost(B);
|
|
662
|
+
__resetRatelimitForTests();
|
|
663
|
+
// Same raw token, different realm -> tokenId(B, raw) is a different key -> miss.
|
|
664
|
+
await expect(Auth.confirmEmail(confirmTok)).rejects.toThrow();
|
|
665
|
+
|
|
666
|
+
setHost(A);
|
|
667
|
+
__resetRatelimitForTests();
|
|
668
|
+
await Auth.confirmEmail(confirmTok); // realm A -> confirms
|
|
669
|
+
setConfirmation(false);
|
|
670
|
+
|
|
671
|
+
// ---- reset token: minted on A, unredeemable on B ----
|
|
672
|
+
setHost(A);
|
|
673
|
+
__clearSentEmails();
|
|
674
|
+
__resetRatelimitForTests();
|
|
675
|
+
await Auth.requestPasswordReset('mia@x.com');
|
|
676
|
+
const resetTok = tokenFromEmail('reset', 'mia@x.com');
|
|
677
|
+
|
|
678
|
+
setHost(B);
|
|
679
|
+
__resetRatelimitForTests();
|
|
680
|
+
await expect(Auth.resetPassword(resetTok, 'mia-pw-new')).rejects.toThrow(); // realm B miss
|
|
681
|
+
|
|
682
|
+
setHost(A);
|
|
683
|
+
__resetRatelimitForTests();
|
|
684
|
+
await Auth.resetPassword(resetTok, 'mia-pw-new'); // realm A -> resets
|
|
685
|
+
|
|
686
|
+
// ---- 2FA login code: minted on A, unusable on B ----
|
|
687
|
+
setHost(A);
|
|
688
|
+
__resetRatelimitForTests();
|
|
689
|
+
await Auth.login('mia', 'mia-pw-new'); // session on A (2FA still off)
|
|
690
|
+
__clearSentEmails();
|
|
691
|
+
await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
|
|
692
|
+
await Auth.confirmTwoFactorSetup(codeFromEmail('mia@x.com')); // 2FA enabled on A
|
|
693
|
+
|
|
694
|
+
__clearSentEmails();
|
|
695
|
+
jar.clear();
|
|
696
|
+
__resetRatelimitForTests();
|
|
697
|
+
let err: unknown;
|
|
698
|
+
try {
|
|
699
|
+
await Auth.login('mia', 'mia-pw-new'); // -> 2FA challenge (twoFaId + code, realm A)
|
|
700
|
+
} catch (e) {
|
|
701
|
+
err = e;
|
|
702
|
+
}
|
|
703
|
+
const twoFaId = (err as TwoFactorRequiredError).twoFaId;
|
|
704
|
+
const loginCode = codeFromEmail('mia@x.com');
|
|
705
|
+
|
|
706
|
+
setHost(B);
|
|
707
|
+
__resetRatelimitForTests();
|
|
708
|
+
// The twoFaId lives in twoFaLogins under realm A; on B its key (and the
|
|
709
|
+
// realm-bound codeHash) miss -> a code from A cannot mint a session on B.
|
|
710
|
+
await expect(Auth.verifyTwoFactor(twoFaId, loginCode)).rejects.toThrow();
|
|
711
|
+
|
|
712
|
+
setHost(A);
|
|
713
|
+
__resetRatelimitForTests();
|
|
714
|
+
const session = await Auth.verifyTwoFactor(twoFaId, loginCode); // realm A -> mints
|
|
715
|
+
expect(session.length).toBeGreaterThan(0);
|
|
716
|
+
},
|
|
717
|
+
180_000,
|
|
718
|
+
);
|
|
637
719
|
});
|