toiljs 0.0.87 → 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.
- package/CHANGELOG.md +5 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +12 -1
- package/build/client/auth.js +82 -4
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +41 -20
- package/build/devserver/config/ratelimit.d.ts +1 -0
- package/build/devserver/config/ratelimit.js +3 -0
- package/build/devserver/email/index.d.ts +10 -0
- package/build/devserver/email/index.js +16 -0
- package/build/devserver/runtime/host.js +33 -49
- package/build/devserver/runtime/module.js +1 -0
- package/examples/basic/client/routes/pq.tsx +12 -2
- package/examples/basic/server/main.ts +0 -1
- package/examples/basic/server/routes/Session.ts +5 -11
- package/package.json +3 -3
- package/server/auth/AuthController.ts +467 -24
- package/server/globals/auth.ts +44 -2
- package/server/globals/email.ts +54 -1
- package/src/client/auth.ts +130 -4
- package/src/devserver/analytics/index.ts +61 -30
- package/src/devserver/config/ratelimit.ts +7 -0
- package/src/devserver/email/index.ts +44 -0
- package/src/devserver/runtime/host.ts +58 -64
- package/src/devserver/runtime/module.ts +1 -0
- package/test/analytics-dev.test.ts +34 -12
- package/test/devserver-email.test.ts +48 -1
- package/test/pqauth-e2e.test.ts +18 -9
- package/test/pqauth-email.test.ts +333 -0
- package/examples/basic/server/routes/Auth.ts +0 -268
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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;
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `
|
|
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(
|
|
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
|
|
168
|
-
* proof-of-possession before storing
|
|
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
|
-
|
|
179
|
-
|
|
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
|
|
185
|
-
//
|
|
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(
|
|
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
|
|
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(
|
|
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
|
}
|