toiljs 0.0.89 → 0.0.91

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
@@ -244,6 +282,49 @@ function mintToken(): Uint8Array {
244
282
  return randomBytes(32);
245
283
  }
246
284
 
285
+ /**
286
+ * A CSPRNG numeric code of `digits` decimal digits (leading zeros kept), using
287
+ * REJECTION SAMPLING so there is NO modulo bias: a byte in [250,256) is rejected
288
+ * (250 = the largest multiple of 10 that is <= 256), so every accepted byte maps
289
+ * uniformly onto 0-9. Each digit is drawn independently until accepted.
290
+ */
291
+ function randomCode(digits: i32): string {
292
+ const buf = new Uint8Array(1);
293
+ let s = '';
294
+ for (let i = 0; i < digits; i++) {
295
+ let b: i32 = 255;
296
+ do {
297
+ crypto.getRandomValues(buf);
298
+ b = <i32>buf[0];
299
+ } while (b >= 250); // reject the biased tail
300
+ s += (b % 10).toString();
301
+ }
302
+ return s;
303
+ }
304
+
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);
310
+ }
311
+
312
+ /** Constant-time equality of two fixed 32-byte SHA-256 hashes (XOR-accumulate,
313
+ * no early exit on the first differing byte). Mirrors the client `bytesEqual`. */
314
+ function ctEqual(a: Uint8Array, b: Uint8Array): bool {
315
+ if (a.length != b.length) return false;
316
+ let diff: i32 = 0;
317
+ for (let i = 0; i < a.length; i++) diff |= (<i32>a[i]) ^ (<i32>b[i]);
318
+ return diff == 0;
319
+ }
320
+
321
+ /** Whether `m` is a 2FA method this build understands (TWOFA_NONE or a supported
322
+ * method). ADD A METHOD: also accept its enum value here. */
323
+ function isKnownTwoFaMethod(m: u8): bool {
324
+ return m == TWOFA_NONE || m == TWOFA_EMAIL;
325
+ // >>> e.g. `|| m == TWOFA_TOTP || m == TWOFA_SMS` <<<
326
+ }
327
+
247
328
  @data
248
329
  class Username {
249
330
  name: string = '';
@@ -306,6 +387,11 @@ class AuthAccount {
306
387
  parallelism: u32 = 0;
307
388
  email: string = '';
308
389
  emailConfirmed: bool = false;
390
+ // APPENDED (append-only, forward-compatible like `email` above): the account's
391
+ // second-factor method (see the TWOFA_* enum). An old row decodes as 0 =
392
+ // TWOFA_NONE (2FA off), the safe default, so existing accounts stay logged in
393
+ // by password alone until they opt in via /auth/2fa/setup.
394
+ twoFactorMethod: u8 = 0;
309
395
  }
310
396
 
311
397
  @data
@@ -317,6 +403,35 @@ class Challenge {
317
403
  exp: u64 = 0;
318
404
  }
319
405
 
406
+ /** The lookup key for a login 2FA challenge: 16 random bytes minted per login.
407
+ * Lives in its OWN collection (twoFaLogins), a SEPARATE NAMESPACE from
408
+ * confirm/reset TokenId, so a confirm/reset token can never resolve here. */
409
+ @data
410
+ class TwoFaId {
411
+ id: Uint8Array = new Uint8Array(0);
412
+ constructor(id: Uint8Array = new Uint8Array(0)) {
413
+ this.id = id;
414
+ }
415
+ }
416
+
417
+ /**
418
+ * A pending second-factor challenge (login OR setup). METHOD-AGNOSTIC lifecycle
419
+ * fields (username / exp / attempts) plus the per-method `codeHash`; `method` is
420
+ * which factor was delivered and `targetMethod` is used ONLY by the setup flow
421
+ * (what to set `account.twoFactorMethod` to on success). Login challenges leave
422
+ * `targetMethod` at 0. `codeHash = SHA-256(username || 0x00 || code)`; the
423
+ * plaintext code is never stored.
424
+ */
425
+ @data
426
+ class TwoFaChallenge {
427
+ username: string = '';
428
+ method: u8 = 0;
429
+ codeHash: Uint8Array = new Uint8Array(0);
430
+ exp: u64 = 0;
431
+ attempts: u32 = 0;
432
+ targetMethod: u8 = 0;
433
+ }
434
+
320
435
  @database
321
436
  class AuthDb {
322
437
  @collection static accounts: Documents<Username, AuthAccount>;
@@ -329,6 +444,14 @@ class AuthDb {
329
444
  // user per kind (O(users)), not one per request (unbounded storage-griefing).
330
445
  @collection static confirmTokenOf: Documents<Username, TokenId>;
331
446
  @collection static resetTokenOf: Documents<Username, TokenId>;
447
+ // SECOND-FACTOR challenges live in their OWN collections, a namespace fully
448
+ // disjoint from confirm/reset tokens (above). A reset/confirm token looked up
449
+ // here finds nothing (wrong collection); a 2FA code/id looked up in the token
450
+ // collections finds nothing. The separation is STRUCTURAL, not a purpose
451
+ // string. `twoFaLogins` is keyed by a random per-login id; `twoFaSetup` is
452
+ // keyed by username = one outstanding setup challenge per user.
453
+ @collection static twoFaLogins: Documents<TwoFaId, TwoFaChallenge>;
454
+ @collection static twoFaSetup: Documents<Username, TwoFaChallenge>;
332
455
  }
333
456
 
334
457
  @rest('auth')
@@ -565,6 +688,40 @@ class Auth {
565
688
  const sessionKey = AuthService.deriveSessionKey(sharedSecret, transcriptHash);
566
689
  const confirm = AuthService.serverConfirmTag(sessionKey, transcriptHash);
567
690
 
691
+ // 3c. SECOND-FACTOR GATE. If this account has 2FA enabled, DO NOT mint a
692
+ // session here. Mint a login 2FA challenge (method-agnostic lifecycle)
693
+ // and deliver the code (per-method), then return ST_TWOFA_REQUIRED
694
+ // WITH the serverConfirm tag (so the client still completes mutual auth
695
+ // and knows the server is genuine BEFORE it prompts for a code) and NO
696
+ // cookie. The challenge is stored in `twoFaLogins` (its own namespace,
697
+ // never resetTokens/confirmTokens). This runs AFTER the signature
698
+ // verify above, so it is not an oracle, and the login `Challenge` was
699
+ // already consumed (getDelete) at the top of this handler.
700
+ if (acct.twoFactorMethod != TWOFA_NONE) {
701
+ const raw = randomBytes(16); // the twoFaId the client echoes to /2fa/verify
702
+ const codeHash = this.twoFaDeliver(
703
+ acct.twoFactorMethod,
704
+ ch.username,
705
+ acct.email,
706
+ 'Your login code',
707
+ );
708
+ if (codeHash.length == 0) return fail(); // unsupported/misconfigured method
709
+ const c2 = new TwoFaChallenge();
710
+ c2.username = ch.username;
711
+ c2.method = acct.twoFactorMethod;
712
+ c2.codeHash = codeHash;
713
+ c2.exp = nowSecs() + TWOFA_TTL_SECS;
714
+ c2.attempts = 0;
715
+ c2.targetMethod = TWOFA_NONE; // a LOGIN challenge changes no method on success
716
+ AuthDb.twoFaLogins.create(new TwoFaId(raw), c2);
717
+
718
+ const w2 = new DataWriter();
719
+ w2.writeU8(ST_TWOFA_REQUIRED);
720
+ w2.writeBytes(raw);
721
+ w2.writeBytes(confirm); // mutual-auth tag: the client verifies the server first
722
+ return Response.bytes(w2.toBytes()); // NO Set-Cookie: no session until 2FA verifies
723
+ }
724
+
568
725
  // 4. Success: mint the session for whatever `@user` this program has (built-in or the app's own
569
726
  // extended one). `__toilEncodeAuthUser` is injected by `--authUser`: it constructs the `@user`
570
727
  // (app fields at their defaults), sets the reserved identity, and encodes it. The stable
@@ -707,6 +864,239 @@ class Auth {
707
864
  return resp;
708
865
  }
709
866
 
867
+ /** POST /auth/2fa/verify body: bytes(twoFaId) str(code)
868
+ * resp: u8(status) [+ bytes(userData)] + Set-Cookie on success.
869
+ * The SECOND factor of a 2FA login: consumes the login challenge minted by
870
+ * login/finish and, on success, mints the session (the FIRST point a cookie
871
+ * is set for a 2FA account). NOT @auth (there is no session yet). The
872
+ * challenge is looked up in `twoFaLogins` ONLY, so a reset/confirm token can
873
+ * never be presented here. Single-use, attempt-limited, TTL'd, rate-limited. */
874
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
875
+ @post('/2fa/verify')
876
+ public twoFaVerify(ctx: RouteContext): Response {
877
+ const r = new DataReader(ctx.request.body);
878
+ const id = r.readBytes();
879
+ const code = r.readString();
880
+ if (!r.ok || id.length == 0) return fail();
881
+
882
+ // RACE-FREE attempt cap: count this guess against a host-side EXACT
883
+ // limiter keyed on THIS challenge (the twoFaId), not the peer IP. Unlike
884
+ // the `ch.attempts` get+patch below, this is atomic across all workers,
885
+ // so a concurrent many-IP attacker cannot exceed TWOFA_MAX_ATTEMPTS
886
+ // 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.
888
+ const rl = RateLimitService.guardKeyed(
889
+ TWOFA_VERIFY_RL_ROUTE,
890
+ TWOFA_RL_STRATEGY,
891
+ TWOFA_MAX_ATTEMPTS as i32,
892
+ TWOFA_TTL_SECS as i32,
893
+ crypto.toHex(id),
894
+ );
895
+ if (rl != null) return rl;
896
+
897
+ const key = new TwoFaId(id);
898
+ // PEEK (not consume): a wrong guess must NOT burn the challenge (a typo
899
+ // would otherwise lock the user out) until the attempt cap is reached.
900
+ const ch = AuthDb.twoFaLogins.get(key);
901
+ if (ch == null) return fail();
902
+ if (nowSecs() >= ch.exp || ch.attempts >= TWOFA_MAX_ATTEMPTS) {
903
+ AuthDb.twoFaLogins.delete(key); // burn an expired / exhausted challenge
904
+ return fail();
905
+ }
906
+ if (!this.twoFaVerifyCode(ch.username, code, ch)) {
907
+ // Wrong code: count the attempt; delete the challenge once the cap is
908
+ // hit so a burned-out challenge cannot be ground further.
909
+ ch.attempts = ch.attempts + 1;
910
+ if (ch.attempts >= TWOFA_MAX_ATTEMPTS) AuthDb.twoFaLogins.delete(key);
911
+ else AuthDb.twoFaLogins.patch(key, ch);
912
+ return fail();
913
+ }
914
+
915
+ // SUCCESS: consume-once, then mint the session EXACTLY like login/finish.
916
+ AuthDb.twoFaLogins.getDelete(key);
917
+ const acct = AuthDb.accounts.get(new Username(ch.username));
918
+ if (acct == null) return fail();
919
+ const domain = authDomain(ctx);
920
+ const toilUserId = ToilUserId.derive(acct.publicKey, ch.username, domain).toBytes();
921
+ const userData = __toilEncodeAuthUser(toilUserId, ch.username);
922
+ const w = new DataWriter();
923
+ w.writeU8(ST_OK);
924
+ w.writeBytes(userData);
925
+ const resp = Response.bytes(w.toBytes());
926
+ resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
927
+ resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
928
+ return resp;
929
+ }
930
+
931
+ /** POST /auth/2fa/setup (@auth) body: u8(targetMethod) resp: u8(ST_OK)
932
+ * Begin enabling or disabling 2FA for the SESSION user. Delivers a proof code
933
+ * and stores a setup challenge in `twoFaSetup` (keyed by username = one
934
+ * outstanding per user); /auth/2fa/setup/verify confirms it. ENABLE proves
935
+ * control of the NEW method (deliver via targetMethod). DISABLE proves control
936
+ * of the CURRENT method (deliver via account.twoFactorMethod) before the
937
+ * factor is removed - anti-hijack. Always a generic ST_OK. */
938
+ @auth
939
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
940
+ @post('/2fa/setup')
941
+ public twoFaSetup(_ctx: RouteContext): Response {
942
+ const r = new DataReader(_ctx.request.body);
943
+ const targetMethod = r.readU8();
944
+ if (!r.ok) return fail();
945
+ if (!isKnownTwoFaMethod(targetMethod)) return fail();
946
+ const u = AuthService.getUser();
947
+ if (u == null) return fail();
948
+ const acct = AuthDb.accounts.get(new Username(u.username));
949
+ if (acct == null) return fail();
950
+
951
+ // Disabling when 2FA is already off: nothing to prove; generic ST_OK.
952
+ if (targetMethod == TWOFA_NONE && acct.twoFactorMethod == TWOFA_NONE) {
953
+ return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
954
+ }
955
+
956
+ // Which method delivers the PROOF code:
957
+ // ENABLE (targetMethod != NONE): the NEW method (prove you control it).
958
+ // DISABLE (targetMethod == NONE): the CURRENT method (prove you still
959
+ // hold the existing factor before it is removed).
960
+ const deliverMethod: u8 = targetMethod != TWOFA_NONE ? targetMethod : acct.twoFactorMethod;
961
+ const codeHash = this.twoFaDeliver(
962
+ deliverMethod,
963
+ u.username,
964
+ acct.email,
965
+ 'Your verification code',
966
+ );
967
+ if (codeHash.length == 0) return fail(); // unsupported deliver method
968
+
969
+ const c = new TwoFaChallenge();
970
+ c.username = u.username;
971
+ c.method = deliverMethod;
972
+ c.codeHash = codeHash;
973
+ c.exp = nowSecs() + TWOFA_TTL_SECS;
974
+ c.attempts = 0;
975
+ c.targetMethod = targetMethod; // what to set account.twoFactorMethod to on success
976
+ // 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);
979
+ return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
980
+ }
981
+
982
+ /** POST /auth/2fa/setup/verify (@auth) body: str(code) resp: u8(ST_OK)
983
+ * Confirms a pending /auth/2fa/setup challenge for the SESSION user: on a
984
+ * correct code, sets account.twoFactorMethod = challenge.targetMethod (enable
985
+ * OR disable) and consumes the challenge. Same single-use + attempt-limit +
986
+ * TTL as /auth/2fa/verify. */
987
+ @auth
988
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
989
+ @post('/2fa/setup/verify')
990
+ public twoFaSetupVerify(_ctx: RouteContext): Response {
991
+ const r = new DataReader(_ctx.request.body);
992
+ const code = r.readString();
993
+ if (!r.ok) return fail();
994
+ const u = AuthService.getUser();
995
+ if (u == null) return fail();
996
+
997
+ // RACE-FREE attempt cap for the setup challenge (same rationale as
998
+ // /2fa/verify): an atomic host-side limiter keyed on the session user, so
999
+ // the `ch.attempts` get+patch below cannot be out-raced by concurrent
1000
+ // submissions. @auth-gated, so lower risk than login, but fixed alike.
1001
+ const srl = RateLimitService.guardKeyed(
1002
+ TWOFA_SETUP_RL_ROUTE,
1003
+ TWOFA_RL_STRATEGY,
1004
+ TWOFA_MAX_ATTEMPTS as i32,
1005
+ TWOFA_TTL_SECS as i32,
1006
+ u.username,
1007
+ );
1008
+ if (srl != null) return srl;
1009
+
1010
+ const key = new Username(u.username);
1011
+ const ch = AuthDb.twoFaSetup.get(key);
1012
+ if (ch == null) return fail();
1013
+ if (nowSecs() >= ch.exp || ch.attempts >= TWOFA_MAX_ATTEMPTS) {
1014
+ AuthDb.twoFaSetup.delete(key);
1015
+ return fail();
1016
+ }
1017
+ if (!this.twoFaVerifyCode(ch.username, code, ch)) {
1018
+ ch.attempts = ch.attempts + 1;
1019
+ if (ch.attempts >= TWOFA_MAX_ATTEMPTS) AuthDb.twoFaSetup.delete(key);
1020
+ else AuthDb.twoFaSetup.patch(key, ch);
1021
+ return fail();
1022
+ }
1023
+
1024
+ // SUCCESS: consume-once + apply the method change.
1025
+ AuthDb.twoFaSetup.getDelete(key);
1026
+ const acct = AuthDb.accounts.get(key);
1027
+ if (acct == null) return fail();
1028
+ acct.twoFactorMethod = ch.targetMethod;
1029
+ AuthDb.accounts.patch(key, acct);
1030
+ return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
1031
+ }
1032
+
1033
+ /** GET /auth/2fa/status (@auth) resp: u8(method) (0 = off, 1 = email, ...)
1034
+ * The session user's current 2FA method, so the UI can show state. */
1035
+ @auth
1036
+ @get('/2fa/status')
1037
+ public twoFaStatus(_ctx: RouteContext): Response {
1038
+ const u = AuthService.getUser();
1039
+ if (u == null) return fail();
1040
+ const acct = AuthDb.accounts.get(new Username(u.username));
1041
+ if (acct == null) return fail();
1042
+ return Response.bytes(new DataWriter().writeU8(acct.twoFactorMethod).toBytes());
1043
+ }
1044
+
1045
+ /**
1046
+ * PER-METHOD 2FA DELIVERY. Generates and delivers a fresh code for `method`
1047
+ * and returns the codeHash to persist for the later verify; returns an EMPTY
1048
+ * array for an unsupported method (the caller fails closed). The plaintext
1049
+ * code never leaves this method.
1050
+ *
1051
+ * >>> ADD A METHOD HERE: a new `case TWOFA_TOTP:` returns the codeHash/secret
1052
+ * binding WITHOUT emailing (the code is the authenticator's own output),
1053
+ * and pairs with a `case TWOFA_TOTP` in {@link twoFaVerifyCode}. The
1054
+ * lifecycle and the login/setup wiring do NOT change. <<<
1055
+ */
1056
+ private twoFaDeliver(method: u8, username: string, email: string, subject: string): Uint8Array {
1057
+ switch (method) {
1058
+ case TWOFA_EMAIL: {
1059
+ const code = randomCode(TWOFA_DIGITS);
1060
+ const text =
1061
+ 'Your verification code is ' +
1062
+ code +
1063
+ '.\nIt expires in a few minutes. If you did not request it, ignore this email.\n';
1064
+ const html =
1065
+ '<p>Your verification code is:</p>' +
1066
+ '<p style="font-size:28px;font-weight:bold;letter-spacing:4px">' +
1067
+ code +
1068
+ '</p>' +
1069
+ '<p>It expires in a few minutes. If you did not request it, ignore this email.</p>';
1070
+ // DETACHED (non-suspending) send: constant-time, no provider-RTT
1071
+ // parking (matches the confirm/reset anti-enumeration posture).
1072
+ EmailService.sendDetached(email, subject, text, '2fa', html);
1073
+ return twoFaCodeHash(username, code);
1074
+ }
1075
+ // >>> case TWOFA_TOTP: { return twoFaSecretBinding(username); } <<<
1076
+ default:
1077
+ return new Uint8Array(0);
1078
+ }
1079
+ }
1080
+
1081
+ /**
1082
+ * PER-METHOD 2FA VERIFICATION: `true` iff `code` satisfies `challenge` for its
1083
+ * method. The method-agnostic lifecycle (peek / attempt-limit / TTL / consume)
1084
+ * lives in the endpoints; only the CODE CHECK is per-method here.
1085
+ *
1086
+ * >>> ADD A METHOD HERE: a new `case TWOFA_TOTP:` runs a TOTP check against the
1087
+ * stored secret binding. <<<
1088
+ */
1089
+ private twoFaVerifyCode(username: string, code: string, challenge: TwoFaChallenge): bool {
1090
+ switch (challenge.method) {
1091
+ case TWOFA_EMAIL:
1092
+ // Constant-time compare of the two 32-byte SHA-256 hashes.
1093
+ return ctEqual(twoFaCodeHash(username, code), challenge.codeHash);
1094
+ // >>> case TWOFA_TOTP: return totpVerify(challenge.codeHash, code); <<<
1095
+ default:
1096
+ return false;
1097
+ }
1098
+ }
1099
+
710
1100
  /** Mint a confirm token for `username`/`email` and email the confirm link.
711
1101
  * Best-effort: a failed send is not fatal (the user can resend).
712
1102
  *
@@ -39,6 +39,13 @@ function fromHex(hex: string): Uint8Array {
39
39
  return out;
40
40
  }
41
41
 
42
+ /** bytes -> lowercase-hex. */
43
+ function toHex(bytes: Uint8Array): string {
44
+ let s = '';
45
+ for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0');
46
+ return s;
47
+ }
48
+
42
49
  /**
43
50
  * The server's PINNED static ML-KEM-768 public key. The client encapsulates to
44
51
  * it; only the genuine server (holder of the matching secret key) can
@@ -353,17 +360,23 @@ export async function login(
353
360
  wipe(sharedSecret);
354
361
  throw new EmailNotConfirmedError();
355
362
  }
356
- if (loginStatus !== 0) {
363
+ // status 3 = 2FA required: the credential verified but the account has a second
364
+ // factor. The server ALSO returns its serverConfirm tag (so we still complete
365
+ // mutual auth below) but mints NO session/cookie. Both the success (0) and the
366
+ // 2FA (3) responses are `bytes(first) bytes(serverConfirm)` -- `first` is the
367
+ // session token on 0 and the twoFaId on 3.
368
+ if (loginStatus !== 0 && loginStatus !== 3) {
357
369
  wipe(sharedSecret);
358
370
  throw new Error('auth: login failed');
359
371
  }
360
- const session = res.readBytes();
372
+ const first = res.readBytes();
361
373
  const serverConfirm = res.readBytes();
362
374
 
363
375
  // 5. Mutual auth: derive the session key K = HMAC(sharedSecret, label || H(M)),
364
376
  // then check the server's tag = HMAC(K, label || H(M)). Only a server that
365
377
  // decapsulated correctly derives the same K, so a valid tag proves its
366
- // identity. Verify before returning the session.
378
+ // identity. Runs for BOTH statuses, so a 2FA prompt only ever appears AFTER
379
+ // the server has authenticated itself.
367
380
  const transcriptHash = await sha256Bytes(message);
368
381
  const sessionKey = await hmacSha256(
369
382
  sharedSecret,
@@ -376,7 +389,14 @@ export async function login(
376
389
  );
377
390
  if (!bytesEqual(expected, serverConfirm)) throw new Error('auth: server authentication failed');
378
391
 
379
- return session; // session token
392
+ if (loginStatus === 3) {
393
+ // The server authenticated itself, but a second factor is required. Surface
394
+ // the twoFaId (hex) so the caller can collect a code and call
395
+ // verifyTwoFactor. NO session exists yet.
396
+ throw new TwoFactorRequiredError(toHex(first));
397
+ }
398
+
399
+ return first; // session token
380
400
  }
381
401
 
382
402
  /** Thrown by {@link login} when the credential is valid but the account's email
@@ -389,6 +409,91 @@ export class EmailNotConfirmedError extends Error {
389
409
  }
390
410
  }
391
411
 
412
+ /** Thrown by {@link login} when the password verified AND the server authenticated
413
+ * itself (mutual auth passed) but the account requires a SECOND FACTOR. No session
414
+ * exists yet. Catch it, collect the delivered code, and call
415
+ * {@link verifyTwoFactor} with `err.twoFaId`. */
416
+ export class TwoFactorRequiredError extends Error {
417
+ /** The opaque login-challenge id (hex) to echo to {@link verifyTwoFactor}. */
418
+ readonly twoFaId: string;
419
+ constructor(twoFaId: string) {
420
+ super('auth: two-factor authentication required');
421
+ this.name = 'TwoFactorRequiredError';
422
+ this.twoFaId = twoFaId;
423
+ }
424
+ }
425
+
426
+ /** The 2FA method values, mirroring the server enum (append-only). */
427
+ export const TwoFactorMethod = { None: 0, Email: 1 } as const;
428
+
429
+ /**
430
+ * Complete a 2FA login: submit the delivered `code` for the challenge `twoFaId`
431
+ * (from {@link TwoFactorRequiredError}). On success the server mints the session
432
+ * (sets the cookies) and returns the opaque session token. Throws one generic
433
+ * error ("code invalid or expired") on any failure -- the code is single-use and
434
+ * attempt-limited server-side.
435
+ */
436
+ export async function verifyTwoFactor(
437
+ twoFaId: string,
438
+ code: string,
439
+ opts: AuthOptions = {},
440
+ ): Promise<Uint8Array> {
441
+ const baseUrl = opts.baseUrl ?? '/auth';
442
+ const res = await postBinary(
443
+ baseUrl,
444
+ '/2fa/verify',
445
+ new DataWriter().writeBytes(fromHex(twoFaId)).writeString(code).toBytes(),
446
+ );
447
+ if (res.readU8() !== 0) throw new Error('auth: two-factor code invalid or expired');
448
+ return res.readBytes(); // opaque session token
449
+ }
450
+
451
+ /**
452
+ * Begin enabling or disabling 2FA for the CURRENT session user. Pass a
453
+ * {@link TwoFactorMethod} value (`Email` to enable email 2FA, `None` to disable).
454
+ * The server delivers a proof code (to the new method when enabling, or the
455
+ * CURRENT method when disabling - anti-hijack); confirm it with
456
+ * {@link confirmTwoFactorSetup}. Requires a valid session.
457
+ */
458
+ export async function setupTwoFactor(method: number, opts: AuthOptions = {}): Promise<void> {
459
+ const baseUrl = opts.baseUrl ?? '/auth';
460
+ const res = await postBinary(
461
+ baseUrl,
462
+ '/2fa/setup',
463
+ new DataWriter().writeU8(method).toBytes(),
464
+ );
465
+ if (res.readU8() !== 0) throw new Error('auth: two-factor setup failed');
466
+ }
467
+
468
+ /**
469
+ * Confirm a pending {@link setupTwoFactor} by submitting the delivered `code`.
470
+ * On success the account's 2FA method is switched (enabled or disabled). Requires
471
+ * a valid session. Throws (generic) if the code is wrong or expired.
472
+ */
473
+ export async function confirmTwoFactorSetup(code: string, opts: AuthOptions = {}): Promise<void> {
474
+ const baseUrl = opts.baseUrl ?? '/auth';
475
+ const res = await postBinary(
476
+ baseUrl,
477
+ '/2fa/setup/verify',
478
+ new DataWriter().writeString(code).toBytes(),
479
+ );
480
+ if (res.readU8() !== 0) throw new Error('auth: two-factor setup confirmation failed');
481
+ }
482
+
483
+ /**
484
+ * The current session user's 2FA method (a {@link TwoFactorMethod} value; `0` =
485
+ * off). Requires a valid session. For the UI to reflect enabled/disabled state.
486
+ */
487
+ export async function twoFactorStatus(opts: AuthOptions = {}): Promise<number> {
488
+ const baseUrl = opts.baseUrl ?? '/auth';
489
+ const res = await fetch(baseUrl + '/2fa/status', {
490
+ method: 'GET',
491
+ credentials: 'same-origin',
492
+ });
493
+ if (!res.ok) throw new Error('auth: request failed');
494
+ return new DataReader(new Uint8Array(await res.arrayBuffer())).readU8();
495
+ }
496
+
392
497
  /** Confirm an account from the one-time token in the emailed link
393
498
  * (`/confirm?token=<hex>`). Throws if the token is invalid or expired. */
394
499
  export async function confirmEmail(token: string, opts: AuthOptions = {}): Promise<void> {
@@ -489,6 +594,11 @@ export const Auth = {
489
594
  resendConfirmation,
490
595
  requestPasswordReset,
491
596
  resetPassword,
597
+ verifyTwoFactor,
598
+ setupTwoFactor,
599
+ confirmTwoFactorSetup,
600
+ twoFactorStatus,
601
+ TwoFactorMethod,
492
602
  buildLoginMessage,
493
603
  LOGIN_CONTEXT,
494
604
  } as const;
@@ -134,9 +134,13 @@ function encodeStats(s: DevTenantStats): Buffer {
134
134
  return Buffer.concat([head, body]);
135
135
  }
136
136
 
137
- /** The metric ids carried in the per-minute ring (mirrors the edge `MINUTE_RING_METRICS`). Only these get
138
- * minute resolution on the 1h/6h ranges; every other metric falls back to the hour ring there. */
139
- const MINUTE_RING_METRICS = new Set<number>([0, 2, 1, 25, 26, 12, 13, 39, 43, 45]);
137
+ /** The metric ids carried in the per-minute ring (mirrors the edge `MINUTE_RING_METRICS` in
138
+ * `toil-backend/src/analytics/ring.rs` EXACTLY). Only these get minute resolution on the 1h/6h ranges;
139
+ * every other metric falls back to the hour ring there. These are the AUTHORITATIVE edge ids
140
+ * (Requests=0, BytesInL1=2, BytesOutL1=1, GasUsed=11, DbOps=12, StreamBytesIn=24, StreamBytesOut=25,
141
+ * MemGrownBytes=38, ConnectedStreamsAvg=42, CommittedMemoryAvg=44) -- must match the reindexed layout or
142
+ * the dev 1h/6h graphs pick the wrong ring for these metrics. */
143
+ const MINUTE_RING_METRICS = new Set<number>([0, 1, 2, 11, 12, 24, 25, 38, 42, 44]);
140
144
 
141
145
  /**
142
146
  * Range id -> (bucketCount, bucketSecs), matching the edge `Range` + minute-ring fallback: 1h/6h are
@@ -241,9 +245,9 @@ export function buildAnalyticsImports(
241
245
  range: number,
242
246
  ): number => {
243
247
  if (domainLen > MAX_DOMAIN_LEN) return ABSENT;
244
- // Valid metric ids are 0..=46 (counters 0..=42 + the 4 gauge avg/peak series); ranges 0..=9
245
- // (H1..D30 plus the appended D60/D90 day ranges).
246
- if (metricId < 0 || metricId > 46 || range < 0 || range > 9) return ABSENT;
248
+ // Valid metric ids are 0..=45 (counters 0..=41 + the 4 gauge avg/peak series 42..=45), matching
249
+ // the edge `MetricId::from_u16` (v < N_METRICS=46); ranges 0..=9 (H1..D30 + D60/D90).
250
+ if (metricId < 0 || metricId > 45 || range < 0 || range > 9) return ABSENT;
247
251
  if (domainLen > 0) {
248
252
  if (!ref.memory) throw new Error('analytics_series called before memory was bound');
249
253
  const m = Buffer.from(ref.memory.buffer);
@@ -80,6 +80,18 @@ describe('dev analytics stub v2 frame parity', () => {
80
80
  expect(b2.readUInt32LE(4)).toBe(60); // bucketSecs
81
81
  expect(b2.readUInt32LE(16)).toBe(60); // count
82
82
 
83
+ // F1 guard: a minute-ring metric OTHER than Requests must ALSO get minute resolution on 1h.
84
+ // GasUsed(11) is in the edge minute ring; a stale dev set fell back to the hour ring here.
85
+ imports.analytics_series(0, 0, 11, 0); // GasUsed, 1h
86
+ const bGas = db.lastResult as unknown as Buffer;
87
+ expect(bGas.readUInt32LE(4)).toBe(60); // minute bucketSecs (was wrongly 3600 pre-fix)
88
+ expect(bGas.readUInt32LE(16)).toBe(60); // 60 minute buckets (was wrongly 1)
89
+ // A metric NOT in the minute ring (Status2xx=3) falls back to the hour ring on 1h.
90
+ imports.analytics_series(0, 0, 3, 0);
91
+ const bStat = db.lastResult as unknown as Buffer;
92
+ expect(bStat.readUInt32LE(4)).toBe(3600); // hour bucketSecs
93
+ expect(bStat.readUInt32LE(16)).toBe(1); // 1 hour bucket
94
+
83
95
  // range 8 (D60) + 9 (D90) -> DAY ring: 60/90 buckets, bucketSecs 86400.
84
96
  imports.analytics_series(0, 0, 0, 8);
85
97
  const b60 = db.lastResult as unknown as Buffer;
@@ -94,5 +106,8 @@ describe('dev analytics stub v2 frame parity', () => {
94
106
  expect(imports.analytics_series(0, 0, 999, 3)).toBeLessThan(0);
95
107
  expect(imports.analytics_series(0, 0, 0, 99)).toBeLessThan(0);
96
108
  expect(imports.analytics_series(0, 0, 0, 10)).toBeLessThan(0);
109
+ // F2: valid ids are 0..=45 (gauges 42..=45); 46 is past the end and must be rejected like the edge.
110
+ expect(imports.analytics_series(0, 0, 46, 3)).toBeLessThan(0);
111
+ expect(imports.analytics_series(0, 0, 45, 3)).toBeGreaterThan(0); // gauge id 45 is valid
97
112
  });
98
113
  });