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.
@@ -0,0 +1,333 @@
1
+ /**
2
+ * End-to-end email-verification + password-reset for the BUILT-IN auth controller
3
+ * (`server/auth/AuthController.ts`, mounted into `examples/basic` via
4
+ * `server: { auth: true }`). Drives the REAL browser client (`src/client/auth.ts`)
5
+ * against the toilscript-compiled example wasm through the same fetch-shim harness
6
+ * as `pqauth-e2e.test.ts`: a `fetch` shim routes the client's requests into
7
+ * `WasmServerModule.dispatch`, and the in-process dev ToilDB persists across
8
+ * dispatches so a register -> login -> confirm/reset flow spans "requests".
9
+ *
10
+ * The confirm/reset one-time tokens are only delivered by email, so this reads
11
+ * them out of the dev email-capture seam (`__sentEmails`) and regexes the token
12
+ * from the emitted link. Confirmation is toggled per case via the plain env var
13
+ * the controller reads (`AUTH_REQUIRE_EMAIL_CONFIRMATION`), set through
14
+ * `process.env` + a dev env-cache clear.
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
21
+
22
+ import { WasmServerModule } from '../src/devserver/index.js';
23
+ import { __resetDbForTests } from '../src/devserver/db/index.js';
24
+ import { __resetRatelimitForTests } from '../src/devserver/config/ratelimit.js';
25
+ import { clearEnvCache } from '../src/devserver/config/dotenv.js';
26
+ import { __sentEmails, __clearSentEmails } from '../src/devserver/email/index.js';
27
+ import { Auth, EmailNotConfirmedError } from '../src/client/auth.js';
28
+ import { DataReader, DataWriter } from '../src/io/codec.js';
29
+
30
+ const EXAMPLE_WASM = path.resolve(
31
+ path.dirname(fileURLToPath(import.meta.url)),
32
+ '../examples/basic/build/server/release.wasm',
33
+ );
34
+
35
+ const haveWasm = fs.existsSync(EXAMPLE_WASM);
36
+
37
+ function loadModule(): WasmServerModule {
38
+ const m = new WasmServerModule(EXAMPLE_WASM);
39
+ m.refresh();
40
+ return m;
41
+ }
42
+
43
+ /** Route the client's `fetch(path, {body})` into the dev wasm dispatcher (with a cookie jar). */
44
+ function installFetchShim(m: WasmServerModule): () => void {
45
+ const original = globalThis.fetch;
46
+ const jar = new Map<string, string>();
47
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
48
+ const url = typeof input === 'string' ? input : input.toString();
49
+ const pathname = new URL(url, 'http://localhost').pathname;
50
+ const bodyBytes =
51
+ init?.body == null ? new Uint8Array(0) : new Uint8Array(init.body as ArrayBuffer);
52
+ const headers: [string, string][] = [
53
+ ['host', 'localhost:3000'],
54
+ ['content-type', 'application/octet-stream'],
55
+ ];
56
+ if (jar.size > 0)
57
+ headers.push(['cookie', [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ')]);
58
+ const r = m.dispatch({
59
+ method: (init?.method ?? 'GET') as 'GET' | 'POST',
60
+ path: pathname,
61
+ headers,
62
+ body: bodyBytes,
63
+ });
64
+ for (const [name, value] of r.headers) {
65
+ if (name.toLowerCase() !== 'set-cookie') continue;
66
+ const pair = value.split(';', 1)[0];
67
+ const eq = pair.indexOf('=');
68
+ if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
69
+ }
70
+ const ab = r.body.buffer.slice(r.body.byteOffset, r.body.byteOffset + r.body.byteLength);
71
+ return {
72
+ ok: r.status >= 200 && r.status < 300,
73
+ status: r.status,
74
+ arrayBuffer: async () => ab,
75
+ text: async () => Buffer.from(r.body).toString('utf8'),
76
+ } as Response;
77
+ }) as typeof fetch;
78
+ return {
79
+ restore: () => {
80
+ globalThis.fetch = original;
81
+ },
82
+ jar,
83
+ };
84
+ }
85
+
86
+ const CONFIRM_ENV = 'AUTH_REQUIRE_EMAIL_CONFIRMATION';
87
+
88
+ /** Toggle the domain's email-confirmation requirement the way the dev env store exposes plain vars:
89
+ * set/unset the `process.env` var, then drop the cached env snapshot so the next `Environment.get`
90
+ * (a per-dispatch host call) re-reads it. */
91
+ function setConfirmation(on: boolean): void {
92
+ if (on) process.env[CONFIRM_ENV] = 'true';
93
+ else delete process.env[CONFIRM_ENV];
94
+ clearEnvCache();
95
+ }
96
+
97
+ function hexToBytes(hex: string): Uint8Array {
98
+ const out = new Uint8Array(hex.length / 2);
99
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
100
+ return out;
101
+ }
102
+
103
+ /** Pull the one-time token out of the most-recent captured confirm/reset email to `to`. */
104
+ function tokenFromEmail(kind: 'confirm' | 'reset', to: string): string {
105
+ // Tokens live in the URL FRAGMENT now (#token=), kept out of server/CDN logs.
106
+ const re = kind === 'confirm' ? /\/confirm#token=([0-9a-f]+)/ : /\/reset#token=([0-9a-f]+)/;
107
+ for (let i = __sentEmails.length - 1; i >= 0; i--) {
108
+ const msg = __sentEmails[i];
109
+ if (msg.to !== to) continue;
110
+ const hit = re.exec(msg.text) ?? re.exec(msg.html);
111
+ if (hit) return hit[1];
112
+ }
113
+ throw new Error(
114
+ `no ${kind} token emailed to ${to}; captured=${JSON.stringify(
115
+ __sentEmails.map((e) => ({ to: e.to, purpose: e.purpose })),
116
+ )}`,
117
+ );
118
+ }
119
+
120
+ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (client <-> example wasm)', () => {
121
+ let restoreFetch: () => void;
122
+ let mod: WasmServerModule;
123
+ let jar: Map<string, string>;
124
+
125
+ beforeEach(() => {
126
+ __resetDbForTests();
127
+ // The controller decorates every route with `@ratelimit`; the dev limiter is a module
128
+ // singleton, so reset it per case to keep the many dispatches across this file isolated.
129
+ __resetRatelimitForTests();
130
+ __clearSentEmails();
131
+ setConfirmation(false); // default: confirmation off unless a case opts in
132
+ mod = loadModule();
133
+ const shim = installFetchShim(mod);
134
+ restoreFetch = shim.restore;
135
+ jar = shim.jar;
136
+ });
137
+ afterEach(() => {
138
+ restoreFetch();
139
+ setConfirmation(false);
140
+ });
141
+
142
+ it(
143
+ 'confirmation OFF (default): register auto-confirms, login succeeds, /auth/me returns the user',
144
+ async () => {
145
+ await Auth.register('ada', 'correct horse battery staple', 'ada@example.com');
146
+ const session = await Auth.login('ada', 'correct horse battery staple');
147
+ expect(session.length).toBeGreaterThan(0);
148
+
149
+ // The controller's `@auth`-gated /auth/me returns bytes(toilUserId) str(username).
150
+ const meRes = await fetch('/auth/me');
151
+ expect(meRes.status).toBe(200);
152
+ const me = new DataReader(new Uint8Array(await meRes.arrayBuffer()));
153
+ expect(me.readBytes().length).toBeGreaterThan(0); // stable toilUserId
154
+ expect(me.readString()).toBe('ada');
155
+
156
+ // No confirmation required -> no email was sent.
157
+ expect(__sentEmails.length).toBe(0);
158
+ },
159
+ 60_000,
160
+ );
161
+
162
+ // ---- security regressions ----
163
+
164
+ it(
165
+ 'reset link is NOT poisonable via a crafted Host header (#6 host-header reset-poisoning)',
166
+ async () => {
167
+ await Auth.register('grace', 'pw-grace-correct', 'grace@x.com');
168
+ __clearSentEmails();
169
+ // Attacker triggers reset/request with a Host that routes to the victim
170
+ // (the edge strip_port -> "victim.com") but, dropped raw into a link,
171
+ // reparents the URL authority to attacker.com.
172
+ const body = new DataWriter().writeString('grace@x.com').toBytes();
173
+ const r = mod.dispatch({
174
+ method: 'POST',
175
+ path: '/auth/reset/request',
176
+ headers: [
177
+ ['host', 'victim.com:@attacker.com'],
178
+ ['content-type', 'application/octet-stream'],
179
+ ],
180
+ body,
181
+ });
182
+ expect(r.status).toBe(200); // always the generic non-enumerating ack
183
+ const msg = __sentEmails.find((e) => e.to === 'grace@x.com');
184
+ expect(msg).toBeTruthy();
185
+ // The poisoned Host is rejected (contains `@`) and the link falls back to
186
+ // a safe origin -> the attacker domain never appears in the emailed link.
187
+ expect((msg!.html + msg!.text).includes('attacker.com')).toBe(false);
188
+ },
189
+ 60_000,
190
+ );
191
+
192
+ it(
193
+ 'a session minted for one tenant does NOT verify for another (#2 cross-tenant session)',
194
+ async () => {
195
+ await Auth.register('heidi', 'pw-heidi-correct', 'heidi@x.com');
196
+ await Auth.login('heidi', 'pw-heidi-correct'); // minted under the shim Host localhost:3000
197
+ const sess = jar.get('toil_sess'); // dev is plain HTTP -> unprefixed cookie
198
+ expect(sess).toBeTruthy();
199
+ const meHeaders = (host: string): [string, string][] => [
200
+ ['host', host],
201
+ ['cookie', 'toil_sess=' + String(sess)],
202
+ ];
203
+ // Same tenant (localhost) -> the @auth-gated /auth/me accepts it.
204
+ const same = mod.dispatch({
205
+ method: 'GET',
206
+ path: '/auth/me',
207
+ headers: meHeaders('localhost:3000'),
208
+ body: new Uint8Array(0),
209
+ });
210
+ expect(same.status).toBe(200);
211
+ // A DIFFERENT tenant Host derives a different session key -> the same
212
+ // cookie fails to open -> 401. A B-minted session cannot bypass A.
213
+ const cross = mod.dispatch({
214
+ method: 'GET',
215
+ path: '/auth/me',
216
+ headers: meHeaders('evil-tenant.com'),
217
+ body: new Uint8Array(0),
218
+ });
219
+ expect(cross.status).toBe(401);
220
+ },
221
+ 60_000,
222
+ );
223
+
224
+ it(
225
+ 'token bound: a new reset request invalidates the previous token (#5c growth bound)',
226
+ async () => {
227
+ await Auth.register('ivan', 'pw-ivan-correct', 'ivan@x.com');
228
+ await Auth.requestPasswordReset('ivan@x.com');
229
+ const token1 = tokenFromEmail('reset', 'ivan@x.com');
230
+ __clearSentEmails();
231
+ await Auth.requestPasswordReset('ivan@x.com'); // mints token2, invalidates token1
232
+ const token2 = tokenFromEmail('reset', 'ivan@x.com');
233
+ expect(token2).not.toBe(token1);
234
+ // The superseded token no longer works...
235
+ await expect(Auth.resetPassword(token1, 'new-pw-unused')).rejects.toThrow();
236
+ // ...only the latest does.
237
+ await Auth.resetPassword(token2, 'new-pw-ivan');
238
+ const session = await Auth.login('ivan', 'new-pw-ivan');
239
+ expect(session.length).toBeGreaterThan(0);
240
+ },
241
+ 60_000,
242
+ );
243
+
244
+ it(
245
+ 'confirmation ON: login is refused until the emailed token confirms the account',
246
+ async () => {
247
+ setConfirmation(true);
248
+ await Auth.register('bob', 'hunter2-correct', 'bob@example.com');
249
+
250
+ // A valid credential is not enough: the domain requires a confirmed email.
251
+ await expect(Auth.login('bob', 'hunter2-correct')).rejects.toThrow(EmailNotConfirmedError);
252
+
253
+ // Read the confirm link out of the captured email and confirm.
254
+ const token = tokenFromEmail('confirm', 'bob@example.com');
255
+ await Auth.confirmEmail(token);
256
+
257
+ // Now login succeeds.
258
+ const session = await Auth.login('bob', 'hunter2-correct');
259
+ expect(session.length).toBeGreaterThan(0);
260
+ },
261
+ 60_000,
262
+ );
263
+
264
+ it(
265
+ 'duplicate email is rejected at registration (distinct "email already in use")',
266
+ async () => {
267
+ await Auth.register('carol', 'carol-pw-strong', 'dupe@x.com');
268
+ await expect(Auth.register('dave', 'dave-pw-strong', 'dupe@x.com')).rejects.toThrow(
269
+ /email already in use/,
270
+ );
271
+ },
272
+ 60_000,
273
+ );
274
+
275
+ it(
276
+ 'password reset round trip: old password stops working, the new one logs in',
277
+ async () => {
278
+ await Auth.register('erin', 'oldpw-erin-strong', 'erin@x.com');
279
+ // Sanity: confirmation is off, so she can log in before the reset.
280
+ expect((await Auth.login('erin', 'oldpw-erin-strong')).length).toBeGreaterThan(0);
281
+
282
+ await Auth.requestPasswordReset('erin@x.com');
283
+ const token = tokenFromEmail('reset', 'erin@x.com');
284
+ await Auth.resetPassword(token, 'newpw-erin-strong');
285
+
286
+ await expect(Auth.login('erin', 'oldpw-erin-strong')).rejects.toThrow(
287
+ /login failed|request failed/,
288
+ );
289
+ expect((await Auth.login('erin', 'newpw-erin-strong')).length).toBeGreaterThan(0);
290
+ },
291
+ 60_000,
292
+ );
293
+
294
+ it(
295
+ 'non-enumeration: reset/resend for an unknown email resolve and send NOTHING',
296
+ async () => {
297
+ __clearSentEmails();
298
+ await expect(Auth.requestPasswordReset('nobody@x.com')).resolves.toBeUndefined();
299
+ await expect(Auth.resendConfirmation('nobody@x.com')).resolves.toBeUndefined();
300
+ expect(__sentEmails.length).toBe(0);
301
+ },
302
+ 60_000,
303
+ );
304
+
305
+ it(
306
+ 'a reset token is one-time: replaying /auth/reset/finish with the consumed token fails',
307
+ async () => {
308
+ await Auth.register('frank', 'oldpw-frank-strong', 'frank@x.com');
309
+ await Auth.requestPasswordReset('frank@x.com');
310
+ const tokenHex = tokenFromEmail('reset', 'frank@x.com');
311
+ await Auth.resetPassword(tokenHex, 'newpw-frank-strong'); // consumes the token
312
+
313
+ // Replay reset/finish with a valid-length pk/proof so the controller reaches the
314
+ // token-consume step, which now finds the token already deleted -> generic fail.
315
+ const body = new DataWriter()
316
+ .writeBytes(hexToBytes(tokenHex))
317
+ .writeBytes(new Uint8Array(1312)) // AuthService.PUBLIC_KEY_LEN (ML-DSA-44)
318
+ .writeBytes(new Uint8Array(2420)) // AuthService.SIGNATURE_LEN
319
+ .toBytes();
320
+ const r = mod.dispatch({
321
+ method: 'POST',
322
+ path: '/auth/reset/finish',
323
+ headers: [
324
+ ['host', 'localhost:3000'],
325
+ ['content-type', 'application/octet-stream'],
326
+ ],
327
+ body,
328
+ });
329
+ expect(r.status).not.toBe(200);
330
+ },
331
+ 60_000,
332
+ );
333
+ });
@@ -1,268 +0,0 @@
1
- import { Response, RouteContext } from 'toiljs/server/runtime';
2
- import { DataReader, DataWriter } from 'data';
3
-
4
- import { encodeSessionUser } from './Session';
5
-
6
- /**
7
- * Toil PQ-Auth: post-quantum password login, end-to-end and runnable under `toiljs dev`.
8
- *
9
- * The password never leaves the browser. The client blinds it through the
10
- * server-keyed OPRF (precomputation-resistant keyed salt), stretches the OPRF
11
- * output with Argon2id into an ML-DSA-44 keypair, and registers only the public
12
- * key (+ a proof-of-possession). Login is a challenge-response that also runs an
13
- * ML-KEM-768 key encapsulation: the server proves its identity by returning a
14
- * confirmation tag only derivable from the decapsulated shared secret (mutual
15
- * auth). See `server/globals/auth.ts` (the `AuthService` global) and the client
16
- * half in `toiljs/client` (`Auth.register` / `Auth.login`).
17
- *
18
- * STORAGE: backed by ToilDB (`@database AuthDb`). Accounts are a `record`
19
- * collection keyed by username; login challenges are a `record` consumed exactly
20
- * once with `getDelete` (atomic fetch-and-delete). The dev server emulates these
21
- * `env.data.*` host imports in process (so register -> login spans requests under
22
- * `toiljs dev`); the production edge backs the SAME API with ScyllaDB.
23
- *
24
- * Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
25
- */
26
-
27
- const AUD = 'toil-demo'; // this service's audience id (server config; never client-echoed)
28
-
29
- // Demo-light Argon2id params (responsive in a browser tab). A real deployment
30
- // uses >= 256 MiB / >= 3 iterations. The client derives against whatever it is
31
- // handed, so this is the single source of truth.
32
- const DEMO_MEM_KIB: u32 = 32768; // 32 MiB
33
- const DEMO_ITERS: u32 = 2;
34
- const DEMO_PAR: u32 = 1;
35
-
36
- const CHALLENGE_TTL_SECS: u64 = 120;
37
- const SESSION_TTL_SECS: u64 = 3600;
38
-
39
- function randomBytes(n: i32): Uint8Array {
40
- const b = new Uint8Array(n);
41
- crypto.getRandomValues(b);
42
- return b;
43
- }
44
-
45
- function nowSecs(): u64 {
46
- return <u64>(Date.now() / 1000);
47
- }
48
-
49
- /** One generic error on every failure path (anti-enumeration, anti-oracle). */
50
- function fail(): Response {
51
- return Response.text('auth: request failed\n', 401);
52
- }
53
-
54
- /**
55
- * Deterministic per-user Argon2id salt (16 bytes). With the OPRF providing
56
- * precomputation resistance, a public/deterministic salt is fine: it only needs
57
- * to be unique per user (the OPRF output already differs per user). Making it
58
- * deterministic means register and login agree with NO stored salt, and an
59
- * unknown user yields the SAME stable salt as a known one would -- no
60
- * enumeration oracle.
61
- */
62
- function deriveSalt(username: string): Uint8Array {
63
- return crypto.sha256Text('toil-demo-salt-v1:' + username).slice(0, 16);
64
- }
65
-
66
- @data
67
- class Username {
68
- name: string = '';
69
- constructor(name: string = '') {
70
- this.name = name;
71
- }
72
- }
73
-
74
- @data
75
- class ChallengeId {
76
- cid: Uint8Array = new Uint8Array(0);
77
- constructor(cid: Uint8Array = new Uint8Array(0)) {
78
- this.cid = cid;
79
- }
80
- }
81
-
82
- @data
83
- class AuthAccount {
84
- username: string = '';
85
- salt: Uint8Array = new Uint8Array(0);
86
- publicKey: Uint8Array = new Uint8Array(0);
87
- memKiB: u32 = 0;
88
- iterations: u32 = 0;
89
- parallelism: u32 = 0;
90
- }
91
-
92
- @data
93
- class Challenge {
94
- cid: Uint8Array = new Uint8Array(0);
95
- username: string = '';
96
- nonce: Uint8Array = new Uint8Array(0);
97
- iat: u64 = 0;
98
- exp: u64 = 0;
99
- }
100
-
101
- @database
102
- class AuthDb {
103
- @collection static accounts: Documents<Username, AuthAccount>;
104
- @collection static challenges: Documents<ChallengeId, Challenge>;
105
- }
106
-
107
- @rest('auth')
108
- class Auth {
109
- /** POST /auth/register/start body: str(username) bytes(blinded)
110
- * resp: u8(status=0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)
111
- * No taken-oracle: always succeeds; register/finish rejects a duplicate. */
112
- @post('/register/start')
113
- public registerStart(ctx: RouteContext): Response {
114
- const r = new DataReader(ctx.request.body);
115
- const username = r.readString();
116
- const blinded = r.readBytes();
117
- if (!r.ok) return fail();
118
- const evaluated = AuthService.oprfEvaluate(username, blinded);
119
- if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
120
-
121
- const w = new DataWriter();
122
- w.writeU8(0);
123
- w.writeU32(DEMO_MEM_KIB);
124
- w.writeU32(DEMO_ITERS);
125
- w.writeU32(DEMO_PAR);
126
- w.writeBytes(deriveSalt(username));
127
- w.writeBytes(evaluated);
128
- return Response.bytes(w.toBytes());
129
- }
130
-
131
- /** POST /auth/register/finish body: str(username) bytes(pk) bytes(regProof)
132
- * resp: u8(status) -- 0 = ok, 1 = username already registered. Verifies
133
- * proof-of-possession before storing the key. */
134
- @post('/register/finish')
135
- public registerFinish(ctx: RouteContext): Response {
136
- const r = new DataReader(ctx.request.body);
137
- const username = r.readString();
138
- const pk = r.readBytes();
139
- const proof = r.readBytes();
140
- if (!r.ok) return fail();
141
- if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
142
- // Already registered: a distinguishable status (not the generic 401) so the
143
- // client can say "username taken, log in instead" rather than a blank error.
144
- if (AuthDb.accounts.exists(new Username(username))) {
145
- return Response.bytes(new DataWriter().writeU8(1).toBytes());
146
- }
147
-
148
- // Proof-of-possession: the client signed buildRegisterMessage with the
149
- // matching secret key, so we confirm it actually holds it.
150
- const regMsg = AuthService.buildRegisterMessage(username, pk);
151
- if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
152
-
153
- const a = new AuthAccount();
154
- a.username = username;
155
- a.salt = deriveSalt(username);
156
- a.publicKey = pk;
157
- a.memKiB = DEMO_MEM_KIB;
158
- a.iterations = DEMO_ITERS;
159
- a.parallelism = DEMO_PAR;
160
- // create-if-absent: a racing duplicate registration loses here, not above.
161
- if (!AuthDb.accounts.create(new Username(username), a)) {
162
- return Response.bytes(new DataWriter().writeU8(1).toBytes());
163
- }
164
- return Response.bytes(new DataWriter().writeU8(0).toBytes());
165
- }
166
-
167
- /** POST /auth/login/start body: str(username) bytes(blinded)
168
- * resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt)
169
- * bytes(nonce) u64(iat) u64(exp) bytes(evaluated)
170
- * Anti-enumeration: ALWAYS OPRF-evaluates (real or decoy key from the same
171
- * seed+username), returns a deterministic per-user salt + constant params,
172
- * and a fresh challenge -- a known and an unknown user are indistinguishable. */
173
- @post('/login/start')
174
- public loginStart(ctx: RouteContext): Response {
175
- const r = new DataReader(ctx.request.body);
176
- const username = r.readString();
177
- const blinded = r.readBytes();
178
- if (!r.ok) return fail();
179
- const evaluated = AuthService.oprfEvaluate(username, blinded);
180
- if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
181
-
182
- const known = AuthDb.accounts.exists(new Username(username));
183
- const cid = randomBytes(16);
184
- const nonce = randomBytes(32);
185
- const iat = nowSecs();
186
- const exp = iat + CHALLENGE_TTL_SECS;
187
-
188
- // Persist only for a real account; the response is identical either way,
189
- // and login/finish for an unknown user fails generically at consume.
190
- if (known) {
191
- const c = new Challenge();
192
- c.cid = cid;
193
- c.username = username;
194
- c.nonce = nonce;
195
- c.iat = iat;
196
- c.exp = exp;
197
- AuthDb.challenges.create(new ChallengeId(cid), c);
198
- }
199
-
200
- const w = new DataWriter();
201
- w.writeBytes(cid);
202
- w.writeString(AUD);
203
- w.writeU32(DEMO_MEM_KIB);
204
- w.writeU32(DEMO_ITERS);
205
- w.writeU32(DEMO_PAR);
206
- w.writeBytes(deriveSalt(username));
207
- w.writeBytes(nonce);
208
- w.writeU64(iat);
209
- w.writeU64(exp);
210
- w.writeBytes(evaluated);
211
- return Response.bytes(w.toBytes());
212
- }
213
-
214
- /** POST /auth/login/finish body: bytes(cid) bytes(ct) bytes(sig)
215
- * resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie */
216
- @post('/login/finish')
217
- public loginFinish(ctx: RouteContext): Response {
218
- const r = new DataReader(ctx.request.body);
219
- const cid = r.readBytes();
220
- const ct = r.readBytes();
221
- const sig = r.readBytes();
222
- if (!r.ok) return fail();
223
-
224
- // 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
225
- const ch = AuthDb.challenges.getDelete(new ChallengeId(cid));
226
- if (ch == null) return fail();
227
- if (nowSecs() >= ch.exp) return fail();
228
-
229
- // 2. Rebuild the message from OUR stored values + the client's ct (and
230
- // the bound params + server key id), load the account key, verify.
231
- const acct = AuthDb.accounts.get(new Username(ch.username));
232
- if (acct == null) return fail();
233
- const message = AuthService.buildLoginMessage(
234
- ch.username,
235
- AUD,
236
- cid,
237
- ch.nonce,
238
- ch.iat,
239
- ch.exp,
240
- ct,
241
- DEMO_MEM_KIB,
242
- DEMO_ITERS,
243
- DEMO_PAR,
244
- AuthService.serverKemKeyId()
245
- );
246
- if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
247
-
248
- // 3. Decapsulate (proves WE hold the KEM key), derive the session key K
249
- // bound to the transcript, and build the confirmation tag the client
250
- // verifies for mutual auth.
251
- const sharedSecret = AuthService.mlkemDecapsulate(ct);
252
- if (sharedSecret.length != AuthService.SHARED_SECRET_LEN) return fail();
253
- const transcriptHash = AuthService.sha256(message);
254
- const sessionKey = AuthService.deriveSessionKey(sharedSecret, transcriptHash);
255
- const confirm = AuthService.serverConfirmTag(sessionKey, transcriptHash);
256
-
257
- // 4. Success: mint the session and return {0, sessionToken, confirm}.
258
- const userData = encodeSessionUser(ch.username);
259
- const w = new DataWriter();
260
- w.writeU8(0);
261
- w.writeBytes(userData); // opaque session token (the readable user payload)
262
- w.writeBytes(confirm);
263
- const resp = Response.bytes(w.toBytes());
264
- resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
265
- resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
266
- return resp;
267
- }
268
- }