toiljs 0.0.88 → 0.0.89

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.
@@ -4,10 +4,12 @@ import { DataReader, DataWriter } from 'data';
4
4
  /**
5
5
  * Built-in Toil PQ-Auth controller: the full post-quantum password-login API
6
6
  * (`/auth/register|login/start|finish`) plus sessions (`/auth/me`, `/auth/logout`),
7
- * mounted automatically when an app opts in with `server: { auth: true }` (or
8
- * `import 'toiljs/server/auth'`). It is a framework-shipped SOURCE file the build
9
- * APPENDS to the toilscript entry set, so its `@data`/`@database`/`@rest` decorators
10
- * weave and its `@rest` class self-mounts at `/auth/*` no hand-written boilerplate.
7
+ * plus EMAIL VERIFICATION (`/auth/confirm`, `/auth/confirm/resend`) and PASSWORD
8
+ * RESET (`/auth/reset/request|start|finish`), mounted automatically when an app
9
+ * opts in with `server: { auth: true }` (or `import 'toiljs/server/auth'`). It is
10
+ * a framework-shipped SOURCE file the build APPENDS to the toilscript entry set,
11
+ * so its `@data`/`@database`/`@rest` decorators weave and its `@rest` class
12
+ * self-mounts at `/auth/*` — no hand-written boilerplate.
11
13
  *
12
14
  * The password never leaves the browser. The client blinds it through the
13
15
  * server-keyed OPRF (precomputation-resistant keyed salt), stretches the OPRF
@@ -18,11 +20,30 @@ import { DataReader, DataWriter } from 'data';
18
20
  * auth). See `server/globals/auth.ts` (the `AuthService` global) and the client
19
21
  * half in `toiljs/client` (`Auth.register` / `Auth.login`).
20
22
  *
23
+ * EMAIL VERIFICATION: registration now collects the user's email. When email
24
+ * confirmation is REQUIRED for the domain (the plain env var
25
+ * `AUTH_REQUIRE_EMAIL_CONFIRMATION=true`, which the edge also force-sets from the
26
+ * per-domain "Dacely setting" `TOIL_AUTH_REQUIRE_EMAIL_CONFIRMATION`), a new
27
+ * account is stored unconfirmed and emailed a one-time confirm link; login is
28
+ * refused (a distinguishable status) until the account is confirmed. When the
29
+ * toggle is off, accounts are auto-confirmed and no email is sent.
30
+ *
31
+ * PASSWORD RESET: a reset is an AUTHORIZED RE-REGISTER. `/reset/request` emails a
32
+ * one-time reset link (always a generic 200, never revealing whether the email
33
+ * exists); the link drives `/reset/start` (peek the token -> OPRF) then
34
+ * `/reset/finish` (consume the token -> verify a fresh proof-of-possession over
35
+ * the NEW public key -> overwrite the stored `publicKey`). The password itself is
36
+ * never involved server-side; only the derived ML-DSA public key changes.
37
+ *
21
38
  * STORAGE: backed by ToilDB (`@database AuthDb`). Accounts are a `record`
22
- * collection keyed by username; login challenges are a `record` consumed exactly
23
- * once with `getDelete` (atomic fetch-and-delete). The dev server emulates these
24
- * `env.data.*` host imports in process (so register -> login spans requests under
25
- * `toiljs dev`); the production edge backs the SAME API with ScyllaDB.
39
+ * collection keyed by username; an `emails` record maps email -> username (a
40
+ * uniqueness index so reset can find an account by email); login challenges and
41
+ * one-time confirm/reset tokens are `record`s consumed exactly once with
42
+ * `getDelete` (atomic fetch-and-delete). Tokens are stored HASHED (only the raw
43
+ * token in the emailed link is usable), each carrying its own expiry (there is no
44
+ * native TTL; expiry is checked against the clock, consume-before-validate). The
45
+ * dev server emulates these `env.data.*` host imports in process; the production
46
+ * edge backs the SAME API with ScyllaDB.
26
47
  *
27
48
  * Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
28
49
  *
@@ -41,6 +62,14 @@ const PAR: u32 = 1;
41
62
 
42
63
  const CHALLENGE_TTL_SECS: u64 = 120;
43
64
  const SESSION_TTL_SECS: u64 = 3600;
65
+ const CONFIRM_TTL_SECS: u64 = 86400; // email confirm link valid 24h
66
+ const RESET_TTL_SECS: u64 = 3600; // password reset link valid 1h
67
+
68
+ // register/finish + login/finish status bytes (0 = ok).
69
+ const ST_OK: u8 = 0;
70
+ const ST_TAKEN: u8 = 1; // username already registered
71
+ const ST_EMAIL_TAKEN: u8 = 2; // email already in use (register)
72
+ const ST_UNCONFIRMED: u8 = 2; // email not confirmed (login) — distinct endpoint, reuses the value
44
73
 
45
74
  // Resolved-and-cached per instance; the audience id (server config, never
46
75
  // client-echoed). Read lazily (not at module top level) so the env host import
@@ -70,6 +99,106 @@ function authDomain(ctx: RouteContext): string {
70
99
  return 'localhost';
71
100
  }
72
101
 
102
+ /**
103
+ * Whether this domain requires a confirmed email before login. A plain
104
+ * (tenant-readable) env var so an app can opt in itself; the edge ALSO force-sets
105
+ * it to "true" from the per-domain platform toggle `HostConfig.require_email_confirmation`
106
+ * (reserved key `TOIL_AUTH_REQUIRE_EMAIL_CONFIRMATION`), so a Dacely per-domain
107
+ * setting can turn it on without the app changing a line.
108
+ */
109
+ function requireConfirmation(): bool {
110
+ const v = Environment.get('AUTH_REQUIRE_EMAIL_CONFIRMATION');
111
+ if (v == null) return false;
112
+ // Lenient truthy, ALIGNED with the edge's HostConfig parse
113
+ // (`!matches!(v.trim(), "0"|"false"|"no"|"off")`). A strict `== "true"` here
114
+ // let a tenant slip a truthy-but-non-canonical value ("1"/"on"/"TRUE"/"true ")
115
+ // past the guest while the edge treated it as "already opted in" and skipped
116
+ // the force-on injection -> the platform mandate was defeatable. Same parse on
117
+ // both sides closes that gap.
118
+ const t = v.trim().toLowerCase();
119
+ return t.length != 0 && t != '0' && t != 'false' && t != 'no' && t != 'off';
120
+ }
121
+
122
+ /**
123
+ * The absolute origin the emailed confirm/reset links point at. Prefer the
124
+ * tenant-set `PUBLIC_BASE_URL` (e.g. `https://app.example.com`); fall back to the
125
+ * request `Host` (assumed https, since auth cookies are `__Host-`/`Secure`), then
126
+ * localhost for dev. Trailing slash trimmed.
127
+ */
128
+ /**
129
+ * A request Host header is safe to use as an emailed-link authority ONLY if it is
130
+ * a bare host[:port] with no userinfo/path/whitespace. The edge routes on a
131
+ * PORT-STRIPPED, lowercased host, so a crafted `Host: victim.com:@attacker.com`
132
+ * routes to the VICTIM tenant yet, dropped raw into `https://<host>/...`,
133
+ * reparents the URL authority to attacker.com (browsers read `victim.com:` as
134
+ * userinfo) -- a classic reset-link poisoning that mails the victim a link whose
135
+ * token is delivered to the attacker. So we hard-reject anything but
136
+ * `[A-Za-z0-9.:-]` plus IPv6 brackets; a poisoned Host yields `null`.
137
+ */
138
+ function safeAuthority(host: string): string | null {
139
+ if (host.length == 0 || host.length > 255) return null;
140
+ for (let i = 0; i < host.length; i++) {
141
+ const c = host.charCodeAt(i);
142
+ const ok =
143
+ (c >= 48 && c <= 57) || // 0-9
144
+ (c >= 65 && c <= 90) || // A-Z
145
+ (c >= 97 && c <= 122) || // a-z
146
+ c == 46 || // .
147
+ c == 45 || // -
148
+ c == 58 || // : (port separator)
149
+ c == 91 || // [ (IPv6)
150
+ c == 93; // ] (IPv6)
151
+ if (!ok) return null;
152
+ }
153
+ return host;
154
+ }
155
+
156
+ /** A tenant-configured base URL is trusted (they own it) but must still be a
157
+ * well-formed absolute http(s) origin with no control/injection chars: this value
158
+ * is concatenated into the emailed link, so a space (a second URL), a quote/angle
159
+ * (href breakout / HTML injection into the tenant's own emails), or a control
160
+ * (CR/LF) must all be rejected. */
161
+ function isAbsoluteHttpUrl(s: string): bool {
162
+ if (!s.startsWith('https://') && !s.startsWith('http://')) return false;
163
+ for (let i = 0; i < s.length; i++) {
164
+ const c = s.charCodeAt(i);
165
+ // Reject controls + space (<=0x20), DEL, and the quote/angle/backtick chars
166
+ // that enable a second URL, an href breakout, or HTML injection.
167
+ if (
168
+ c <= 0x20 ||
169
+ c == 0x7f ||
170
+ c == 0x22 || // "
171
+ c == 0x27 || // '
172
+ c == 0x3c || // <
173
+ c == 0x3e || // >
174
+ c == 0x60 // `
175
+ ) {
176
+ return false;
177
+ }
178
+ }
179
+ return true;
180
+ }
181
+
182
+ /**
183
+ * The absolute origin the emailed confirm/reset links point at. Prefer the
184
+ * tenant-set `PUBLIC_BASE_URL` (they own it, validated). The request Host is
185
+ * ATTACKER-CONTROLLED, so it is used ONLY after strict authority validation
186
+ * ({@link safeAuthority}); a poisoned or absent Host falls back to localhost,
187
+ * NEVER an attacker-supplied origin. This closes host-header reset-poisoning.
188
+ */
189
+ function baseUrl(ctx: RouteContext): string {
190
+ const configured = Environment.get('PUBLIC_BASE_URL');
191
+ if (configured != null && configured.length > 0 && isAbsoluteHttpUrl(configured)) {
192
+ let b = configured;
193
+ while (b.endsWith('/')) b = b.slice(0, b.length - 1);
194
+ return b;
195
+ }
196
+ const host = ctx.request.header('host');
197
+ const safe = host != null ? safeAuthority(host) : null;
198
+ if (safe != null) return 'https://' + safe;
199
+ return 'http://localhost:3000';
200
+ }
201
+
73
202
  function randomBytes(n: i32): Uint8Array {
74
203
  const b = new Uint8Array(n);
75
204
  crypto.getRandomValues(b);
@@ -85,6 +214,11 @@ function fail(): Response {
85
214
  return Response.text('auth: request failed\n', 401);
86
215
  }
87
216
 
217
+ /** A generic 200 that never reveals whether an email/account exists. */
218
+ function ackOk(): Response {
219
+ return Response.text('ok\n', 200);
220
+ }
221
+
88
222
  /**
89
223
  * Deterministic per-user Argon2id salt (16 bytes). With the OPRF providing
90
224
  * precomputation resistance, a public/deterministic salt is fine: it only needs
@@ -97,6 +231,19 @@ function deriveSalt(username: string): Uint8Array {
97
231
  return crypto.sha256Text('toil-auth-salt-v1:' + username).slice(0, 16);
98
232
  }
99
233
 
234
+ /** The one-time-token lookup key: SHA-256 of the raw token bytes. Storing only
235
+ * the hash means a leak of the token store yields nothing usable — the raw
236
+ * token lives only in the emailed link. */
237
+ function tokenId(raw: Uint8Array): TokenId {
238
+ return new TokenId(crypto.sha256(raw));
239
+ }
240
+
241
+ /** Mint a fresh 32-byte token; returns the raw bytes (for the link) — the store
242
+ * key is its hash. */
243
+ function mintToken(): Uint8Array {
244
+ return randomBytes(32);
245
+ }
246
+
100
247
  @data
101
248
  class Username {
102
249
  name: string = '';
@@ -105,6 +252,38 @@ class Username {
105
252
  }
106
253
  }
107
254
 
255
+ @data
256
+ class EmailKey {
257
+ email: string = '';
258
+ constructor(email: string = '') {
259
+ this.email = email;
260
+ }
261
+ }
262
+
263
+ @data
264
+ class EmailOwner {
265
+ username: string = '';
266
+ constructor(username: string = '') {
267
+ this.username = username;
268
+ }
269
+ }
270
+
271
+ @data
272
+ class TokenId {
273
+ hash: Uint8Array = new Uint8Array(0);
274
+ constructor(hash: Uint8Array = new Uint8Array(0)) {
275
+ this.hash = hash;
276
+ }
277
+ }
278
+
279
+ /** A one-time confirm/reset token record: which account it authorizes + its
280
+ * absolute expiry (unix secs). Consumed with `getDelete`. */
281
+ @data
282
+ class TokenRec {
283
+ username: string = '';
284
+ exp: u64 = 0;
285
+ }
286
+
108
287
  @data
109
288
  class ChallengeId {
110
289
  cid: Uint8Array = new Uint8Array(0);
@@ -115,12 +294,18 @@ class ChallengeId {
115
294
 
116
295
  @data
117
296
  class AuthAccount {
297
+ // The original credential fields keep their byte positions; `email` +
298
+ // `emailConfirmed` are APPENDED so this is a forward-compatible, append-only
299
+ // @data change (an old row decodes with emailConfirmed=false, the strict/safe
300
+ // default) rather than a breaking mid-struct reorder the deploy gate rejects.
118
301
  username: string = '';
119
302
  salt: Uint8Array = new Uint8Array(0);
120
303
  publicKey: Uint8Array = new Uint8Array(0);
121
304
  memKiB: u32 = 0;
122
305
  iterations: u32 = 0;
123
306
  parallelism: u32 = 0;
307
+ email: string = '';
308
+ emailConfirmed: bool = false;
124
309
  }
125
310
 
126
311
  @data
@@ -135,14 +320,23 @@ class Challenge {
135
320
  @database
136
321
  class AuthDb {
137
322
  @collection static accounts: Documents<Username, AuthAccount>;
323
+ @collection static emails: Documents<EmailKey, EmailOwner>; // email -> username (uniqueness index)
138
324
  @collection static challenges: Documents<ChallengeId, Challenge>;
325
+ @collection static confirmTokens: Documents<TokenId, TokenRec>;
326
+ @collection static resetTokens: Documents<TokenId, TokenRec>;
327
+ // username -> its CURRENT outstanding confirm/reset token id, so minting a new
328
+ // one can invalidate the previous: outstanding tokens stay bounded at one per
329
+ // user per kind (O(users)), not one per request (unbounded storage-griefing).
330
+ @collection static confirmTokenOf: Documents<Username, TokenId>;
331
+ @collection static resetTokenOf: Documents<Username, TokenId>;
139
332
  }
140
333
 
141
334
  @rest('auth')
142
335
  class Auth {
143
336
  /** POST /auth/register/start body: str(username) bytes(blinded)
144
337
  * resp: u8(status=0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)
145
- * No taken-oracle: always succeeds; register/finish rejects a duplicate. */
338
+ * No taken-oracle: always succeeds; register/finish rejects a duplicate.
339
+ * Reused verbatim by password reset (the OPRF for the NEW password). */
146
340
  @ratelimit(RateLimit.SlidingWindow, 5, 60)
147
341
  @post('/register/start')
148
342
  public registerStart(ctx: RouteContext): Response {
@@ -154,7 +348,7 @@ class Auth {
154
348
  if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
155
349
 
156
350
  const w = new DataWriter();
157
- w.writeU8(0);
351
+ w.writeU8(ST_OK);
158
352
  w.writeU32(MEM_KIB);
159
353
  w.writeU32(ITERS);
160
354
  w.writeU32(PAR);
@@ -163,31 +357,47 @@ class Auth {
163
357
  return Response.bytes(w.toBytes());
164
358
  }
165
359
 
166
- /** POST /auth/register/finish body: str(username) bytes(pk) bytes(regProof)
167
- * resp: u8(status) -- 0 = ok, 1 = username already registered. Verifies
168
- * proof-of-possession before storing the key. */
360
+ /** POST /auth/register/finish body: str(username) str(email) bytes(pk) bytes(regProof)
361
+ * resp: u8(status) -- 0 = ok, 1 = username taken, 2 = email in use. Verifies
362
+ * proof-of-possession before storing. When confirmation is required the
363
+ * account is stored UNCONFIRMED and emailed a confirm link; otherwise it is
364
+ * auto-confirmed. */
169
365
  @ratelimit(RateLimit.SlidingWindow, 5, 60)
170
366
  @post('/register/finish')
171
367
  public registerFinish(ctx: RouteContext): Response {
172
368
  const r = new DataReader(ctx.request.body);
173
369
  const username = r.readString();
370
+ const email = r.readString();
174
371
  const pk = r.readBytes();
175
372
  const proof = r.readBytes();
176
373
  if (!r.ok) return fail();
177
374
  if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
178
- // Already registered: a distinguishable status (not the generic 401) so the
179
- // client can say "username taken, log in instead" rather than a blank error.
180
- if (AuthDb.accounts.exists(new Username(username))) {
181
- return Response.bytes(new DataWriter().writeU8(1).toBytes());
182
- }
375
+ if (username.length == 0 || username.length > 64) return fail();
376
+ if (email.length == 0 || email.length > 254) return fail();
183
377
 
184
- // Proof-of-possession: the client signed buildRegisterMessage with the
185
- // matching secret key, so we confirm it actually holds it.
378
+ // Proof-of-possession FIRST (before any existence check): the client
379
+ // signed buildRegisterMessage with the matching secret key. Verifying up
380
+ // front means register/finish is not a cheap PRE-crypto oracle for "is
381
+ // this username/email registered" -- a probe must present a valid ML-DSA
382
+ // PoP, not just a well-formed body.
186
383
  const regMsg = AuthService.buildRegisterMessage(username, pk);
187
384
  if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
188
385
 
386
+ // Distinguishable statuses (not the generic 401) so the UI can say
387
+ // "username taken" / "email in use". Signup intentionally leaks existence
388
+ // (a product choice, now gated behind the PoP above); reset never does.
389
+ if (AuthDb.accounts.exists(new Username(username))) {
390
+ return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
391
+ }
392
+ if (AuthDb.emails.exists(new EmailKey(email))) {
393
+ return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
394
+ }
395
+
396
+ const mustConfirm = requireConfirmation();
189
397
  const a = new AuthAccount();
190
398
  a.username = username;
399
+ a.email = email;
400
+ a.emailConfirmed = !mustConfirm; // auto-confirm when confirmation is off
191
401
  a.salt = deriveSalt(username);
192
402
  a.publicKey = pk;
193
403
  a.memKiB = MEM_KIB;
@@ -195,9 +405,63 @@ class Auth {
195
405
  a.parallelism = PAR;
196
406
  // create-if-absent: a racing duplicate registration loses here, not above.
197
407
  if (!AuthDb.accounts.create(new Username(username), a)) {
198
- return Response.bytes(new DataWriter().writeU8(1).toBytes());
408
+ return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
409
+ }
410
+ // Reserve the email -> username index (uniqueness for reset-by-email). A
411
+ // racing duplicate email loses here: roll back the account we just created
412
+ // so a lost email race can't orphan a username whose email index points at
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));
416
+ return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
417
+ }
418
+
419
+ if (mustConfirm) {
420
+ this.issueConfirmation(ctx, username, email);
421
+ }
422
+ return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
423
+ }
424
+
425
+ /** POST /auth/confirm body: bytes(rawToken) resp: u8(0 ok | else fail)
426
+ * Consumes the one-time confirm token and marks the account confirmed. The
427
+ * emailed link points at the app page `/confirm?token=<hex>`, which POSTs the
428
+ * raw token here. */
429
+ @ratelimit(RateLimit.SlidingWindow, 10, 60)
430
+ @post('/confirm')
431
+ public confirm(ctx: RouteContext): Response {
432
+ const r = new DataReader(ctx.request.body);
433
+ const raw = r.readBytes();
434
+ if (!r.ok || raw.length == 0) return fail();
435
+ // Consume FIRST (a replayed/expired token still burns), then validate.
436
+ const rec = AuthDb.confirmTokens.getDelete(tokenId(raw));
437
+ if (rec == null) return fail();
438
+ if (nowSecs() >= rec.exp) return fail();
439
+ const acct = AuthDb.accounts.get(new Username(rec.username));
440
+ if (acct == null) return fail();
441
+ if (!acct.emailConfirmed) {
442
+ acct.emailConfirmed = true;
443
+ AuthDb.accounts.patch(new Username(rec.username), acct);
444
+ }
445
+ return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
446
+ }
447
+
448
+ /** POST /auth/confirm/resend body: str(email) resp: generic 200 always.
449
+ * Re-issues a confirm link if that email maps to an unconfirmed account.
450
+ * Never reveals whether the email exists. */
451
+ @ratelimit(RateLimit.SlidingWindow, 3, 300)
452
+ @post('/confirm/resend')
453
+ public confirmResend(ctx: RouteContext): Response {
454
+ const r = new DataReader(ctx.request.body);
455
+ const email = r.readString();
456
+ if (!r.ok) return fail();
457
+ const owner = AuthDb.emails.get(new EmailKey(email));
458
+ if (owner != null) {
459
+ const acct = AuthDb.accounts.get(new Username(owner.username));
460
+ if (acct != null && !acct.emailConfirmed) {
461
+ this.issueConfirmation(ctx, owner.username, email);
462
+ }
199
463
  }
200
- return Response.bytes(new DataWriter().writeU8(0).toBytes());
464
+ return ackOk();
201
465
  }
202
466
 
203
467
  /** POST /auth/login/start body: str(username) bytes(blinded)
@@ -249,7 +513,8 @@ class Auth {
249
513
  }
250
514
 
251
515
  /** POST /auth/login/finish body: bytes(cid) bytes(ct) bytes(sig)
252
- * resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie */
516
+ * resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie
517
+ * status 2 = email not confirmed (when confirmation is required). */
253
518
  @ratelimit(RateLimit.SlidingWindow, 5, 60)
254
519
  @post('/login/finish')
255
520
  public loginFinish(ctx: RouteContext): Response {
@@ -283,6 +548,14 @@ class Auth {
283
548
  );
284
549
  if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
285
550
 
551
+ // 2b. Email-confirmation gate: a valid credential is not enough when the
552
+ // domain requires a confirmed email. Distinguishable status so the
553
+ // client can prompt "confirm your email / resend". Checked AFTER the
554
+ // signature so it is not an oracle for unconfirmed-account existence.
555
+ if (requireConfirmation() && !acct.emailConfirmed) {
556
+ return Response.bytes(new DataWriter().writeU8(ST_UNCONFIRMED).toBytes());
557
+ }
558
+
286
559
  // 3. Decapsulate (proves WE hold the KEM key), derive the session key K
287
560
  // bound to the transcript, and build the confirmation tag the client
288
561
  // verifies for mutual auth.
@@ -300,7 +573,7 @@ class Auth {
300
573
  const toilUserId = ToilUserId.derive(acct.publicKey, ch.username, domain).toBytes();
301
574
  const userData = __toilEncodeAuthUser(toilUserId, ch.username);
302
575
  const w = new DataWriter();
303
- w.writeU8(0);
576
+ w.writeU8(ST_OK);
304
577
  w.writeBytes(userData); // opaque session token (the readable user payload)
305
578
  w.writeBytes(confirm);
306
579
  const resp = Response.bytes(w.toBytes());
@@ -309,6 +582,106 @@ class Auth {
309
582
  return resp;
310
583
  }
311
584
 
585
+ /** POST /auth/reset/request body: str(email) resp: generic 200 always.
586
+ * Mints a one-time reset token and emails a reset link IF the email maps to
587
+ * an account. Never reveals whether the email exists (always 200, always the
588
+ * same shape) — the standard non-enumerating "if that email exists, we sent
589
+ * a link" behaviour. */
590
+ @ratelimit(RateLimit.SlidingWindow, 3, 300)
591
+ @post('/reset/request')
592
+ public resetRequest(ctx: RouteContext): Response {
593
+ const r = new DataReader(ctx.request.body);
594
+ const email = r.readString();
595
+ if (!r.ok) return fail();
596
+ const owner = AuthDb.emails.get(new EmailKey(email));
597
+ if (owner != null && AuthDb.accounts.exists(new Username(owner.username))) {
598
+ // Invalidate this user's previous reset token (bound to one outstanding).
599
+ const prev = AuthDb.resetTokenOf.getDelete(new Username(owner.username));
600
+ if (prev != null) AuthDb.resetTokens.delete(prev);
601
+ const raw = mintToken();
602
+ const tid = tokenId(raw);
603
+ const rec = new TokenRec();
604
+ rec.username = owner.username;
605
+ rec.exp = nowSecs() + RESET_TTL_SECS;
606
+ AuthDb.resetTokens.create(tid, rec);
607
+ // If a concurrent reset request won the pointer index, roll back the
608
+ // token we just minted so exactly one stays outstanding (no orphan).
609
+ if (!AuthDb.resetTokenOf.create(new Username(owner.username), tid)) {
610
+ AuthDb.resetTokens.delete(tid);
611
+ }
612
+ // Token in the URL FRAGMENT, not the query string: a fragment is never
613
+ // sent to the server (so it can't land in edge/CDN access logs) nor in
614
+ // the Referer header. The reset page reads it client-side from
615
+ // location.hash and should history.replaceState() to scrub it.
616
+ const link = baseUrl(ctx) + '/reset#token=' + crypto.toHex(raw);
617
+ this.sendResetEmail(email, link);
618
+ }
619
+ return ackOk();
620
+ }
621
+
622
+ /** POST /auth/reset/start body: bytes(rawToken) bytes(blinded)
623
+ * resp: u8(0) str(username) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)
624
+ * PEEKS the reset token (does not consume it — that happens at finish),
625
+ * returns the account's username + KDF params + the OPRF evaluation of the
626
+ * NEW blinded password so the client can derive the new keypair. A bad or
627
+ * expired token fails generically. */
628
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
629
+ @post('/reset/start')
630
+ public resetStart(ctx: RouteContext): Response {
631
+ const r = new DataReader(ctx.request.body);
632
+ const raw = r.readBytes();
633
+ const blinded = r.readBytes();
634
+ if (!r.ok || raw.length == 0) return fail();
635
+ const rec = AuthDb.resetTokens.get(tokenId(raw));
636
+ if (rec == null) return fail();
637
+ if (nowSecs() >= rec.exp) return fail();
638
+ const evaluated = AuthService.oprfEvaluate(rec.username, blinded);
639
+ if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
640
+
641
+ const w = new DataWriter();
642
+ w.writeU8(ST_OK);
643
+ w.writeString(rec.username);
644
+ w.writeU32(MEM_KIB);
645
+ w.writeU32(ITERS);
646
+ w.writeU32(PAR);
647
+ w.writeBytes(deriveSalt(rec.username));
648
+ w.writeBytes(evaluated);
649
+ return Response.bytes(w.toBytes());
650
+ }
651
+
652
+ /** POST /auth/reset/finish body: bytes(rawToken) bytes(newPk) bytes(regProof)
653
+ * resp: u8(0 ok | else fail). CONSUMES the reset token, verifies a fresh
654
+ * proof-of-possession over the NEW public key, and overwrites the account's
655
+ * stored publicKey (a reset is an authorized re-register; only the derived
656
+ * ML-DSA key changes). */
657
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
658
+ @post('/reset/finish')
659
+ public resetFinish(ctx: RouteContext): Response {
660
+ const r = new DataReader(ctx.request.body);
661
+ const raw = r.readBytes();
662
+ const pk = r.readBytes();
663
+ const proof = r.readBytes();
664
+ if (!r.ok || raw.length == 0) return fail();
665
+ if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
666
+ // Consume FIRST (single use), then validate.
667
+ const rec = AuthDb.resetTokens.getDelete(tokenId(raw));
668
+ if (rec == null) return fail();
669
+ if (nowSecs() >= rec.exp) return fail();
670
+ const acct = AuthDb.accounts.get(new Username(rec.username));
671
+ if (acct == null) return fail();
672
+
673
+ const regMsg = AuthService.buildRegisterMessage(rec.username, pk);
674
+ if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
675
+
676
+ // Overwrite ONLY the login verifier. Salt/params are deterministic
677
+ // constants (unchanged); a successful reset also implies email ownership,
678
+ // so confirm the account while we are here.
679
+ acct.publicKey = pk;
680
+ acct.emailConfirmed = true;
681
+ AuthDb.accounts.patch(new Username(rec.username), acct);
682
+ return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
683
+ }
684
+
312
685
  /** GET /auth/me (@auth: 401 without a valid session) -> the typed user
313
686
  * (bytes(toilUserId) str(username)). `AuthService.getUser()` is auto-typed
314
687
  * to the built-in `@user` with no type argument. */
@@ -333,4 +706,74 @@ class Auth {
333
706
  resp.setCookie(AuthService.clearUserCookie());
334
707
  return resp;
335
708
  }
709
+
710
+ /** Mint a confirm token for `username`/`email` and email the confirm link.
711
+ * Best-effort: a failed send is not fatal (the user can resend).
712
+ *
713
+ * Uses the DETACHED (non-suspending) send: `confirm/resend` returns the same
714
+ * generic 200 whether or not the email maps to an account, but on the edge a
715
+ * suspending `EmailService.send` would park the "account exists" path for the
716
+ * mailer's provider RTT while the miss path returned instantly — a trivially
717
+ * measurable email-enumeration timing oracle. `sendDetached` queues in
718
+ * constant time, equalizing both paths. (Residual: on the exists path the
719
+ * token mint above still does a sub-ms ToilDB write the miss path skips; a
720
+ * fully constant-time guarantee would also equalize that DB work.) */
721
+ private issueConfirmation(ctx: RouteContext, username: string, email: string): void {
722
+ // Invalidate this user's previous confirm token (bound to one outstanding).
723
+ const prev = AuthDb.confirmTokenOf.getDelete(new Username(username));
724
+ if (prev != null) AuthDb.confirmTokens.delete(prev);
725
+ const raw = mintToken();
726
+ const tid = tokenId(raw);
727
+ const rec = new TokenRec();
728
+ rec.username = username;
729
+ rec.exp = nowSecs() + CONFIRM_TTL_SECS;
730
+ AuthDb.confirmTokens.create(tid, rec);
731
+ // If a concurrent request won the pointer index, roll back the token we
732
+ // just minted so exactly one stays outstanding (no orphan).
733
+ if (!AuthDb.confirmTokenOf.create(new Username(username), tid)) {
734
+ AuthDb.confirmTokens.delete(tid);
735
+ }
736
+ // Token in the URL FRAGMENT (see resetRequest): kept out of server/CDN
737
+ // logs and the Referer header; the confirm page reads it from location.hash.
738
+ const link = baseUrl(ctx) + '/confirm#token=' + crypto.toHex(raw);
739
+ const subject = 'Confirm your account';
740
+ const text = 'Confirm your account by opening this link:\n' + link + '\n';
741
+ const html =
742
+ '<p>Confirm your account by clicking the link below:</p>' +
743
+ '<p><a href="' +
744
+ link +
745
+ '">Confirm my account</a></p>' +
746
+ '<p>Or paste this into your browser:<br>' +
747
+ link +
748
+ '</p>';
749
+ // DETACHED (non-suspending) send: constant-time, no provider-RTT parking on
750
+ // the "account exists" path (see the enumeration note on this method).
751
+ EmailService.sendDetached(email, subject, text, 'verify', html);
752
+ }
753
+
754
+ /** Email a password-reset link. Best-effort (the request path already
755
+ * returned a generic 200).
756
+ *
757
+ * Uses the DETACHED (non-suspending) send for the SAME anti-enumeration
758
+ * reason as {@link issueConfirmation}: `/reset/request` always returns a
759
+ * generic 200, so the send must not make the "email exists" path park for the
760
+ * provider RTT (a timing oracle). `sendDetached` queues in constant time.
761
+ * (Residual: the reset-token mint on the exists path still does a sub-ms
762
+ * ToilDB write the miss path skips.) */
763
+ private sendResetEmail(email: string, link: string): void {
764
+ const subject = 'Reset your password';
765
+ const text = 'Reset your password by opening this link:\n' + link + '\n';
766
+ const html =
767
+ '<p>We received a request to reset your password. Click the link below:</p>' +
768
+ '<p><a href="' +
769
+ link +
770
+ '">Reset my password</a></p>' +
771
+ '<p>If you did not request this, you can ignore this email.</p>' +
772
+ '<p>Or paste this into your browser:<br>' +
773
+ link +
774
+ '</p>';
775
+ // DETACHED (non-suspending) send: constant-time, closes the reset-request
776
+ // email-enumeration timing oracle (see the note above).
777
+ EmailService.sendDetached(email, subject, text, 'reset', html);
778
+ }
336
779
  }