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.
- package/CHANGELOG.md +5 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +20 -0
- package/build/client/auth.js +55 -3
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +2 -2
- package/examples/basic/server/models/SiteAnalytics.ts +3 -1
- package/package.json +1 -1
- package/server/auth/AuthController.ts +390 -0
- package/src/client/auth.ts +114 -4
- package/src/devserver/analytics/index.ts +10 -6
- package/test/analytics-dev.test.ts +15 -0
- package/test/pqauth-email.test.ts +305 -1
|
@@ -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
|
*
|
package/src/client/auth.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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`
|
|
138
|
-
* minute resolution on the 1h/6h ranges;
|
|
139
|
-
|
|
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..=
|
|
245
|
-
// (H1..D30
|
|
246
|
-
if (metricId < 0 || metricId >
|
|
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
|
});
|