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