toiljs 0.0.52 → 0.0.54

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.
@@ -11,7 +11,7 @@
11
11
  * JSON, byte-identical to the server's `AuthService.buildLoginMessage`.
12
12
  */
13
13
 
14
- import { argon2id, sha256, createSHA256 } from 'hash-wasm';
14
+ import { argon2id, createSHA256, createHMAC } from 'hash-wasm';
15
15
  import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
16
16
  import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
17
17
  import { ristretto255_oprf } from '@noble/curves/ed25519.js';
@@ -22,7 +22,9 @@ import { DataReader, DataWriter } from 'toiljs/io';
22
22
  export const LOGIN_CONTEXT = 'qauth:login:v1';
23
23
  /** Registration proof-of-possession context (binds a sig to "register"). */
24
24
  export const REGISTER_CONTEXT = 'qauth:register:v1';
25
- /** Domain separator for the server's mutual-auth confirmation tag. */
25
+ /** Domain separators for the session-key derivation and the confirmation tag.
26
+ * Byte-identical to the server. */
27
+ export const SESSION_KEY_LABEL = 'toil-session-key-v1';
26
28
  export const SERVER_CONFIRM_LABEL = 'toil-server-confirm-v1';
27
29
 
28
30
  /** ML-KEM-768 sizes (FIPS 203). */
@@ -98,6 +100,16 @@ async function sha256Bytes(data: Uint8Array): Promise<Uint8Array> {
98
100
  return h.digest('binary') as unknown as Uint8Array;
99
101
  }
100
102
 
103
+ /** HMAC-SHA256(key, msg) -> 32 raw bytes, via hash-wasm (pure WASM, works in an
104
+ * insecure context). Mirrors the server's `AuthService` HMAC for the session-key
105
+ * derivation and the mutual-auth confirmation tag. */
106
+ async function hmacSha256(key: Uint8Array, msg: Uint8Array): Promise<Uint8Array> {
107
+ const h = await createHMAC(createSHA256(), key);
108
+ h.init();
109
+ h.update(msg);
110
+ return h.digest('binary') as unknown as Uint8Array;
111
+ }
112
+
101
113
  function concatBytes(...parts: Uint8Array[]): Uint8Array {
102
114
  let n = 0;
103
115
  for (const p of parts) n += p.length;
@@ -118,8 +130,11 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
118
130
  return diff === 0;
119
131
  }
120
132
 
121
- /** The canonical login message `M`, fixed binary layout (see the server's
122
- * `AuthService.buildLoginMessage`). Both ends MUST produce identical bytes. */
133
+ /** The canonical login message `M` -- ONE fixed binary layout, byte-identical to
134
+ * the server's `AuthService.buildLoginMessage`. Binds the ML-KEM ciphertext (so
135
+ * the signature commits to the key encapsulation), the Argon2id params (so a
136
+ * MITM cannot slip a downgrade past the signature), and the server KEM key id
137
+ * (so it commits to which server key was encapsulated to). */
123
138
  export function buildLoginMessage(
124
139
  sub: string,
125
140
  aud: string,
@@ -127,32 +142,14 @@ export function buildLoginMessage(
127
142
  nonce: Uint8Array,
128
143
  iat: bigint,
129
144
  exp: bigint,
130
- ): Uint8Array {
131
- return new DataWriter()
132
- .writeU8(1)
133
- .writeString(sub)
134
- .writeString(aud)
135
- .writeBytes(cid)
136
- .writeBytes(nonce)
137
- .writeU64(iat)
138
- .writeU64(exp)
139
- .toBytes();
140
- }
141
-
142
- /** Login message `M` v2: the v1 fields plus the ML-KEM ciphertext, so the
143
- * ML-DSA signature binds the key encapsulation. Byte-identical to the server's
144
- * `AuthService.buildLoginMessageV2`. */
145
- export function buildLoginMessageV2(
146
- sub: string,
147
- aud: string,
148
- cid: Uint8Array,
149
- nonce: Uint8Array,
150
- iat: bigint,
151
- exp: bigint,
152
145
  ciphertext: Uint8Array,
146
+ memKiB: number,
147
+ iterations: number,
148
+ parallelism: number,
149
+ serverKemKeyId: Uint8Array,
153
150
  ): Uint8Array {
154
151
  return new DataWriter()
155
- .writeU8(2)
152
+ .writeU8(1)
156
153
  .writeString(sub)
157
154
  .writeString(aud)
158
155
  .writeBytes(cid)
@@ -160,6 +157,10 @@ export function buildLoginMessageV2(
160
157
  .writeU64(iat)
161
158
  .writeU64(exp)
162
159
  .writeBytes(ciphertext)
160
+ .writeU32(memKiB)
161
+ .writeU32(iterations)
162
+ .writeU32(parallelism)
163
+ .writeBytes(serverKemKeyId)
163
164
  .toBytes();
164
165
  }
165
166
 
@@ -170,7 +171,6 @@ export function buildRegisterMessage(username: string, publicKey: Uint8Array): U
170
171
  return new DataWriter().writeU8(1).writeString(username).writeBytes(publicKey).toBytes();
171
172
  }
172
173
 
173
- // ---- wire codecs (the example `Auth` @rest controller mirrors these) -------
174
174
 
175
175
  function decodeKdf(r: DataReader): KdfParams {
176
176
  return {
@@ -199,7 +199,7 @@ export interface AuthOptions {
199
199
  }
200
200
 
201
201
  /**
202
- * Register a new account (OPAQUE-style). The password never leaves the browser:
202
+ * Register a new account. The password never leaves the browser:
203
203
  * it is blinded and run through the server-keyed OPRF, the OPRF output is
204
204
  * stretched with Argon2id into an ML-DSA-44 keypair, and ONLY the public key
205
205
  * (plus a proof-of-possession signature) is submitted. Throws on failure.
@@ -249,11 +249,13 @@ export async function register(username: string, password: string, opts: AuthOpt
249
249
  '/register/finish',
250
250
  new DataWriter().writeString(username).writeBytes(publicKey).writeBytes(regProof).toBytes(),
251
251
  );
252
- if (finish.readU8() !== 0) throw new Error('auth: registration rejected');
252
+ const finishStatus = finish.readU8();
253
+ if (finishStatus === 1) throw new Error('auth: username already registered (log in instead)');
254
+ if (finishStatus !== 0) throw new Error('auth: registration rejected');
253
255
  }
254
256
 
255
257
  /**
256
- * Log in (OPAQUE-style, with ML-KEM mutual auth). Steps:
258
+ * Log in (challenge-response with ML-KEM mutual auth). Steps:
257
259
  * 1. Blind the password; `login/start` returns the challenge + the OPRF
258
260
  * evaluation (a fully-formed response even for unknown users -> no oracle).
259
261
  * 2. Finalize the OPRF -> keyed salt -> seed -> ML-DSA keypair.
@@ -292,9 +294,14 @@ export async function login(username: string, password: string, opts: AuthOption
292
294
  const oprfOutput = oprf.finalize(pw, blind, evaluated);
293
295
  const seed = await deriveSeed(oprfOutput, kdf);
294
296
 
295
- // 3. Encapsulate to the pinned server KEM key, build + sign the v2 message.
297
+ // 3. Encapsulate to the pinned server KEM key; build + sign the message,
298
+ // which binds the ciphertext, the KDF params, and the server key id.
296
299
  const { cipherText, sharedSecret } = ml_kem768.encapsulate(SERVER_KEM_PUBLIC_KEY);
297
- const message = buildLoginMessageV2(username, aud, cid, nonce, iat, exp, cipherText);
300
+ const serverKemKeyId = await sha256Bytes(SERVER_KEM_PUBLIC_KEY);
301
+ const message = buildLoginMessage(
302
+ username, aud, cid, nonce, iat, exp,
303
+ cipherText, kdf.memKiB, kdf.iterations, kdf.parallelism, serverKemKeyId,
304
+ );
298
305
  let signature: Uint8Array;
299
306
  try {
300
307
  const kp = ml_dsa44.keygen(seed);
@@ -321,135 +328,18 @@ export async function login(username: string, password: string, opts: AuthOption
321
328
  const session = res.readBytes();
322
329
  const serverConfirm = res.readBytes();
323
330
 
324
- // 5. Mutual auth: only a server that decapsulated correctly can produce
325
- // SHA-256(label || sharedSecret || sha256(M)). Verify before trusting.
331
+ // 5. Mutual auth: derive the session key K = HMAC(sharedSecret, label || H(M)),
332
+ // then check the server's tag = HMAC(K, label || H(M)). Only a server that
333
+ // decapsulated correctly derives the same K, so a valid tag proves its
334
+ // identity. Verify before returning the session.
326
335
  const transcriptHash = await sha256Bytes(message);
327
- const expected = await sha256Bytes(
328
- concatBytes(utf8(SERVER_CONFIRM_LABEL), sharedSecret, transcriptHash),
329
- );
336
+ const sessionKey = await hmacSha256(sharedSecret, concatBytes(utf8(SESSION_KEY_LABEL), transcriptHash));
330
337
  wipe(sharedSecret);
338
+ const expected = await hmacSha256(sessionKey, concatBytes(utf8(SERVER_CONFIRM_LABEL), transcriptHash));
331
339
  if (!bytesEqual(expected, serverConfirm)) throw new Error('auth: server authentication failed');
332
340
 
333
341
  return session; // session token
334
342
  }
335
343
 
336
- /** Lowercase hex of `bytes`. */
337
- function toHex(bytes: Uint8Array): string {
338
- let s = '';
339
- for (const b of bytes) s += b.toString(16).padStart(2, '0');
340
- return s;
341
- }
342
-
343
- /** The signed identity proof produced by {@link proveIdentity}. */
344
- export interface IdentityProof {
345
- /** The wire envelope to POST to `/pq/verify`: `str(sub) str(token)
346
- * bytes(publicKey) bytes(signature)`, where `token` is the edge's
347
- * HMAC-signed challenge. The server re-opens the token, rebuilds the login
348
- * message from the values inside it, and `AuthService.verifyLogin`s it. */
349
- readonly envelope: Uint8Array;
350
- /** First bytes of the 1312-byte ML-DSA-44 public key, for display. */
351
- readonly publicKeyHex: string;
352
- /** First bytes of the SERVER-issued nonce that was signed, for display. */
353
- readonly nonceHex: string;
354
- /** Signature length (always 2420 for ML-DSA-44), for display. */
355
- readonly signatureLen: number;
356
- /** Argon2id wall-clock spent deriving the keypair, ms (for display). */
357
- readonly deriveMs: number;
358
- }
359
-
360
- /** Deterministic 16-byte Argon2id salt for the demo, so the same
361
- * username + password always maps to the same identity (keypair).
362
- * Uses hash-wasm's SHA-256 (pure WebAssembly), not `crypto.subtle`, so it
363
- * works in an insecure context (plain HTTP), where `crypto.subtle` is
364
- * undefined. */
365
- async function demoSalt(username: string): Promise<Uint8Array> {
366
- const hex = await sha256('pq-demo|' + username);
367
- const out = new Uint8Array(16);
368
- for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
369
- return out;
370
- }
371
-
372
- /**
373
- * DEMO helper: run the full post-quantum challenge-response in the browser.
374
- * Fetches a SERVER-issued challenge (`GET {baseUrl}/challenge`), stretches the
375
- * password with Argon2id into an ML-DSA-44 keypair, signs the login message
376
- * built from the SERVER's nonce/cid/iat/exp, and returns the wire envelope the
377
- * edge verifies (`AuthService.verifyLogin`). The secret key and seed are wiped
378
- * before returning; only the public key + signature leave the tab.
379
- *
380
- * The nonce is server-chosen and tamper-proof (the challenge token is
381
- * HMAC-signed by the edge), so a client cannot pre-sign or substitute its own.
382
- * It is still NOT the full production login -- there is no single-use consume, so
383
- * within the challenge TTL a captured proof could be replayed; that needs an
384
- * atomic store (see {@link login} and server/routes/Auth.ts). Demo-light
385
- * Argon2id params (16 MiB / 2 passes) keep it responsive in a tab; a real
386
- * deployment uses >= 256 MiB.
387
- */
388
- export async function proveIdentity(
389
- username: string,
390
- password: string,
391
- opts: { baseUrl?: string } = {},
392
- ): Promise<IdentityProof> {
393
- const baseUrl = opts.baseUrl ?? '/pq';
394
-
395
- // 1. Server-issued challenge: aud, cid, nonce, iat, exp, and the signed token.
396
- const cres = await fetch(baseUrl + '/challenge', { credentials: 'same-origin' });
397
- if (!cres.ok) throw new Error('pq: challenge request failed');
398
- const cr = new DataReader(new Uint8Array(await cres.arrayBuffer()));
399
- const aud = cr.readString();
400
- const cid = cr.readBytes();
401
- const nonce = cr.readBytes();
402
- const iat = cr.readU64();
403
- const exp = cr.readU64();
404
- const token = cr.readString();
405
-
406
- // 2. Derive the keypair and sign the message built from the SERVER's values.
407
- const salt = await demoSalt(username);
408
- const t0 = Date.now();
409
- const seed = await argon2id({
410
- password: new TextEncoder().encode(password.normalize('NFKC')),
411
- salt,
412
- iterations: 2,
413
- parallelism: 1,
414
- memorySize: 16 * 1024, // 16 MiB: demo-light, responsive in a tab
415
- hashLength: SEED_LEN,
416
- outputType: 'binary',
417
- });
418
- const deriveMs = Date.now() - t0;
419
-
420
- const message = buildLoginMessage(username, aud, cid, nonce, iat, exp);
421
- let publicKey: Uint8Array;
422
- let signature: Uint8Array;
423
- try {
424
- const kp = ml_dsa44.keygen(seed);
425
- publicKey = kp.publicKey;
426
- try {
427
- signature = ml_dsa44.sign(message, kp.secretKey, {
428
- context: new TextEncoder().encode(LOGIN_CONTEXT),
429
- });
430
- } finally {
431
- wipe(kp.secretKey);
432
- }
433
- } finally {
434
- wipe(seed);
435
- }
436
-
437
- // 3. Envelope: sub + the server's token + the public key + the signature.
438
- const envelope = new DataWriter()
439
- .writeString(username)
440
- .writeString(token)
441
- .writeBytes(publicKey)
442
- .writeBytes(signature)
443
- .toBytes();
444
-
445
- return {
446
- envelope,
447
- publicKeyHex: toHex(publicKey.slice(0, 16)),
448
- nonceHex: toHex(nonce.slice(0, 16)),
449
- signatureLen: signature.length,
450
- deriveMs,
451
- };
452
- }
453
-
454
344
  /** The client auth surface, grouped for `Auth.register` / `Auth.login` use. */
455
- export const Auth = { register, login, proveIdentity, buildLoginMessage, LOGIN_CONTEXT } as const;
345
+ export const Auth = { register, login, buildLoginMessage, LOGIN_CONTEXT } as const;
@@ -10,8 +10,8 @@
10
10
 
11
11
  export { mount } from './routing/mount.js';
12
12
  export { Router } from './routing/Router.js';
13
- export { Auth, register as authRegister, login as authLogin, proveIdentity, buildLoginMessage, LOGIN_CONTEXT } from './auth.js';
14
- export type { KdfParams, AuthOptions, IdentityProof } from './auth.js';
13
+ export { Auth, register as authRegister, login as authLogin, buildLoginMessage, LOGIN_CONTEXT } from './auth.js';
14
+ export type { KdfParams, AuthOptions } from './auth.js';
15
15
  export { Link } from './navigation/Link.js';
16
16
  export type { LinkProps } from './navigation/Link.js';
17
17
  export { NavLink, matchActive } from './navigation/NavLink.js';
@@ -122,7 +122,7 @@ export const TOIL_SERVER_ENV_DTS =
122
122
  `declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }\n` +
123
123
  `declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }\n` +
124
124
  `declare namespace TwoFactor { const DEFAULT_TTL_SECS: u64; const DEFAULT_DIGITS: i32; function setSecret(secret: Uint8Array): void; function issue(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorIssue; function send(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorChallenge; function verify(token: string, recipient: string, code: string): bool; }\n` +
125
- `declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function getUser(): __ToilAuthUser | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; const KEM_CIPHERTEXT_LEN: i32; const KEM_SECRET_KEY_LEN: i32; const KEM_PUBLIC_KEY_LEN: i32; const SHARED_SECRET_LEN: i32; const OPRF_ELEMENT_LEN: i32; const OPRF_SEED_LEN: i32; const SERVER_CONFIRM_LABEL: string; function setOprfSeed(seed: Uint8Array): void; function setServerKemSecretKey(secretKey: Uint8Array): void; function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array; function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array; function sha256(data: Uint8Array): Uint8Array; function buildLoginMessageV2(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64, ciphertext: Uint8Array): Uint8Array; function serverConfirmTag(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array; const REGISTER_CONTEXT: string; function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array; function verifyRegister(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n` +
125
+ `declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function getUser(): __ToilAuthUser | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64, ciphertext: Uint8Array, memKiB: u32, iterations: u32, parallelism: u32, serverKemKeyId: Uint8Array): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; const KEM_CIPHERTEXT_LEN: i32; const KEM_SECRET_KEY_LEN: i32; const KEM_PUBLIC_KEY_LEN: i32; const SHARED_SECRET_LEN: i32; const OPRF_ELEMENT_LEN: i32; const OPRF_SEED_LEN: i32; const SESSION_KEY_LABEL: string; const SERVER_CONFIRM_LABEL: string; function setOprfSeed(seed: Uint8Array): void; function setServerKemSecretKey(secretKey: Uint8Array): void; function setServerKemPublicKey(publicKey: Uint8Array): void; function serverKemKeyId(): Uint8Array; function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array; function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array; function sha256(data: Uint8Array): Uint8Array; function deriveSessionKey(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array; function serverConfirmTag(sessionKey: Uint8Array, transcriptHash: Uint8Array): Uint8Array; const REGISTER_CONTEXT: string; function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array; function verifyRegister(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n` +
126
126
  `interface __ToilAuthUser {}\n`;
127
127
 
128
128
  /**
@@ -260,7 +260,7 @@ export function buildCryptoImports(
260
260
  },
261
261
 
262
262
  // RFC 9497 OPRF (mode 0x00, ristretto255-SHA512) server evaluation for
263
- // the OPAQUE-style keyed salt. Mirrors the edge host
263
+ // the keyed-salt OPRF. Mirrors the edge host
264
264
  // (`voprf_evaluate_import.rs` / the `voprf` crate): derive the per-user
265
265
  // key from (seed, info=username) and blind-evaluate the client's blinded
266
266
  // element, writing the 32-byte evaluated element to `outPtr`. Backed by
@@ -1,21 +1,18 @@
1
1
  /**
2
2
  * DEV-ONLY persistent key-value store host imports (`env::kv.*`).
3
3
  *
4
- * ============================ REMOVE KV LATER ============================
5
- * This exists ONLY so the post-quantum auth example can run the full
6
- * register -> login chain end-to-end under `toiljs dev`. A tenant's wasm linear
7
- * memory is wiped after every request, so account records and login challenges
8
- * cannot live in the guest across the two round trips; they need an external
9
- * store. This module is a single-process `Map` standing in for that store.
4
+ * REMOVE LATER. This exists ONLY so the post-quantum auth example can run the
5
+ * full register -> login chain end-to-end under `toiljs dev`. A tenant's wasm
6
+ * linear memory is wiped after every request, so account records and login
7
+ * challenges cannot live in the guest across the two round trips; they need an
8
+ * external store. This module is a single-process `Map` standing in for that.
10
9
  *
11
10
  * It is intentionally NOT registered on the production Rust edge
12
11
  * (`toil-backend` `HOST_IMPORTS`), so a module using `kv.*` will not instantiate
13
- * there. REPLACE THIS once toildb is implemented: move the example's account +
14
- * challenge stores onto toildb (which provides the atomic fetch-and-delete the
15
- * challenge consume needs) and delete this whole module. DO NOT ship this `Map`
16
- * as a production storage path: it is single-instance, unbounded, and lost on
17
- * restart.
18
- * ========================================================================
12
+ * there. Once toildb is implemented, move the example's account + challenge
13
+ * stores onto toildb (which provides the atomic fetch-and-delete the challenge
14
+ * consume needs) and delete this whole module. DO NOT ship this `Map` as a
15
+ * production storage path: it is single-instance, unbounded, and lost on restart.
19
16
  *
20
17
  * Wire ABI (mirrors the crypto imports' caller-allocated-buffer convention):
21
18
  * kv.put(keyPtr, keyLen, valPtr, valLen) -> void
@@ -40,20 +40,32 @@ function loadModule(): WasmServerModule {
40
40
  /** Route the client's `fetch(path, {body})` into the dev wasm dispatcher. */
41
41
  function installFetchShim(m: WasmServerModule): () => void {
42
42
  const original = globalThis.fetch;
43
+ const jar = new Map<string, string>();
43
44
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
44
45
  const url = typeof input === 'string' ? input : input.toString();
45
46
  const pathname = new URL(url, 'http://localhost').pathname;
46
47
  const bodyBytes =
47
48
  init?.body == null ? new Uint8Array(0) : new Uint8Array(init.body as ArrayBuffer);
49
+ const headers: [string, string][] = [
50
+ ['host', 'localhost:3000'],
51
+ ['content-type', 'application/octet-stream'],
52
+ ];
53
+ if (jar.size > 0)
54
+ headers.push(['cookie', [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ')]);
48
55
  const r = m.dispatch({
49
56
  method: (init?.method ?? 'GET') as 'GET' | 'POST',
50
57
  path: pathname,
51
- headers: [
52
- ['host', 'localhost:3000'],
53
- ['content-type', 'application/octet-stream'],
54
- ],
58
+ headers,
55
59
  body: bodyBytes,
56
60
  });
61
+ // Fold any Set-Cookie (`name=value; Path=/; ...`) into the jar so the next
62
+ // request replays it, exactly like a browser.
63
+ for (const [name, value] of r.headers) {
64
+ if (name.toLowerCase() !== 'set-cookie') continue;
65
+ const pair = value.split(';', 1)[0];
66
+ const eq = pair.indexOf('=');
67
+ if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
68
+ }
57
69
  const ab = r.body.buffer.slice(r.body.byteOffset, r.body.byteOffset + r.body.byteLength);
58
70
  return {
59
71
  ok: r.status >= 200 && r.status < 300,
@@ -86,6 +98,26 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
86
98
  // verified against the client's own shared secret.
87
99
  const session = await Auth.login('ada', 'correct horse battery staple');
88
100
  expect(session.length).toBeGreaterThan(0);
101
+
102
+ // Regression guard: the freshly minted session cookie (captured by the
103
+ // shim jar) must be ACCEPTED by the `@auth`-gated `/session/me`, which
104
+ // runs in a separate fresh wasm instance from `login/finish`. The bug
105
+ // was that login configured a different HMAC secret than the verify
106
+ // path used, so the MAC failed and `/session/me` returned 401.
107
+ const meRes = await fetch('/session/me');
108
+ expect(meRes.status).toBe(200);
109
+ const me = new DataReader(new Uint8Array(await meRes.arrayBuffer()));
110
+ expect(me.readString()).toBe('ada');
111
+ expect(me.readBool()).toBe(false); // 'ada' is not 'root' -> not admin
112
+ },
113
+ 60_000,
114
+ );
115
+
116
+ it(
117
+ 'rejects /session/me with no session cookie (the @auth gate holds)',
118
+ async () => {
119
+ const meRes = await fetch('/session/me');
120
+ expect(meRes.status).toBe(401);
89
121
  },
90
122
  60_000,
91
123
  );
@@ -106,6 +138,19 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
106
138
  },
107
139
  60_000,
108
140
  );
141
+
142
+ it(
143
+ 'rejects a duplicate registration with a clear (not generic) message',
144
+ async () => {
145
+ await Auth.register('dupe', 'correct horse battery staple');
146
+ // Second registration of the same username must say so explicitly,
147
+ // not return the generic "request failed".
148
+ await expect(Auth.register('dupe', 'correct horse battery staple')).rejects.toThrow(
149
+ /already registered/,
150
+ );
151
+ },
152
+ 60_000,
153
+ );
109
154
  });
110
155
 
111
156
  // Lower-level wire checks that don't need the heavy Argon2id derivation.
@@ -1,127 +0,0 @@
1
- import { Response, RouteContext, SecureCookies, base64UrlEncode, base64UrlDecode } from 'toiljs/server/runtime';
2
- import { DataReader, DataWriter } from 'data';
3
-
4
- import { encodeSessionUser } from './Session';
5
-
6
- /**
7
- * Post-quantum identity demo (server half), challenge-response.
8
- *
9
- * 1. GET /pq/challenge -> the edge mints a fresh nonce + cid + iat/exp and
10
- * returns them PLUS an HMAC-signed `token` over those values. The token is
11
- * the server-issued challenge: signed with a server-only key, it proves
12
- * "the edge issued exactly this" WITHOUT any cross-request storage (the
13
- * guest's memory is wiped every request).
14
- * 2. POST /pq/verify -> the client signs the login message built from the
15
- * SERVER's nonce/cid/iat/exp (ML-DSA-44, derived from the password) and
16
- * returns {sub, token, publicKey, signature}. The edge re-opens the token
17
- * (rejecting a forged or expired one), rebuilds the message from the values
18
- * INSIDE the token (never client-echoed), and verifies the signature via
19
- * `crypto.mldsa_verify` (`AuthService.verifyLogin`).
20
- *
21
- * The nonce is server-chosen and tamper-proof, and the challenge is time-bound,
22
- * so a client cannot pre-sign or substitute its own nonce. What this stateless
23
- * form does NOT have is single-use: within the TTL a captured {token, signature}
24
- * could be replayed, because that needs an atomic consume against a store (Redis
25
- * GETDEL / SQL DELETE RETURNING). The production login in server/routes/Auth.ts
26
- * does exactly that; see docs/auth.md. Pairs with client/routes/pq.tsx.
27
- */
28
-
29
- const AUD = 'pq-demo'; // this demo's audience id (server config; never client-echoed)
30
- const CHALLENGE_TTL_SECS: u64 = 120;
31
-
32
- /** Server-only key for signing challenge tokens (demo constant; a real
33
- * deployment uses a per-deployment secret, like the session secret). */
34
- function challengeKey(): Uint8Array {
35
- return crypto.sha256Text('toil-pq-demo-challenge-key-v1');
36
- }
37
-
38
- function randomBytes(n: i32): Uint8Array {
39
- const b = new Uint8Array(n);
40
- crypto.getRandomValues(b);
41
- return b;
42
- }
43
-
44
- @rest('pq')
45
- class PqDemo {
46
- /** GET /pq/challenge
47
- * resp: str(aud) bytes(cid) bytes(nonce) u64(iat) u64(exp) str(token) */
48
- @get('/challenge')
49
- public challenge(_ctx: RouteContext): Response {
50
- const cid = randomBytes(16);
51
- const nonce = randomBytes(32);
52
- const iat = Time.nowSeconds();
53
- const exp = iat + CHALLENGE_TTL_SECS;
54
-
55
- // Sign (iat, exp, cid, nonce) so /verify can confirm the edge issued
56
- // this exact challenge with no stored state.
57
- const blob = new DataWriter()
58
- .writeU64(iat)
59
- .writeU64(exp)
60
- .writeBytes(cid)
61
- .writeBytes(nonce)
62
- .toBytes();
63
- const token = SecureCookies.signed(challengeKey()).sign('pqchal', base64UrlEncode(blob));
64
-
65
- const w = new DataWriter();
66
- w.writeString(AUD);
67
- w.writeBytes(cid);
68
- w.writeBytes(nonce);
69
- w.writeU64(iat);
70
- w.writeU64(exp);
71
- w.writeString(token);
72
- return Response.bytes(w.toBytes());
73
- }
74
-
75
- /** POST /pq/verify
76
- * body: str(sub) str(token) bytes(publicKey 1312) bytes(signature 2420)
77
- * resp: text VALID / INVALID */
78
- @post('/verify')
79
- public verify(ctx: RouteContext): Response {
80
- const r = new DataReader(ctx.request.body);
81
- const sub = r.readString();
82
- const token = r.readString();
83
- const pk = r.readBytes();
84
- const sig = r.readBytes();
85
- if (!r.ok) return Response.text('malformed envelope\n', 400);
86
-
87
- // 1. Re-open the challenge token: must be server-issued + untampered.
88
- const blobB64 = SecureCookies.signed(challengeKey()).unsign('pqchal', token);
89
- if (blobB64 == null) return Response.text('INVALID: forged or unsigned challenge\n', 401);
90
- const blob = base64UrlDecode(blobB64);
91
- if (blob == null) return Response.text('INVALID: malformed challenge\n', 401);
92
- const br = new DataReader(blob);
93
- const iat = br.readU64();
94
- const exp = br.readU64();
95
- const cid = br.readBytes();
96
- const nonce = br.readBytes();
97
- if (!br.ok) return Response.text('INVALID: malformed challenge\n', 401);
98
- if (Time.nowSeconds() >= exp) return Response.text('INVALID: challenge expired\n', 401);
99
-
100
- // TODO(db): single-use consume is NOT implemented yet (no KV/DB host
101
- // binding available). The real fix is an ATOMIC consume of `cid` here --
102
- // Redis GETDEL / SQL DELETE ... RETURNING -- so a given challenge verifies
103
- // at most once. Until that exists, this token is replayable within its
104
- // TTL: a captured {token, signature} re-verifies until `exp`. The
105
- // production login (server/routes/Auth.ts) shows the atomic-consume shape.
106
-
107
- // 2. Rebuild the message from the SERVER's values (inside the token,
108
- // never client-echoed) and verify the ML-DSA-44 signature.
109
- const message = AuthService.buildLoginMessage(sub, AUD, cid, nonce, iat, exp);
110
- if (!AuthService.verifyLogin(pk, message, sig)) {
111
- return Response.text('INVALID: signature did not verify\n', 401);
112
- }
113
-
114
- // 3. FULL AUTH: a valid post-quantum proof logs the user in. Mint the
115
- // signed session for the proven `sub` via the @user codec, plus the
116
- // readable companion. Every `@auth` route now recognises this user
117
- // and `AuthService.getUser()` returns `{ username: sub, ... }`.
118
- const userData = encodeSessionUser(sub);
119
- const resp = Response.text(
120
- 'VALID: ML-DSA-44 (FIPS 204) verified; session established (@auth ready)\n',
121
- 200,
122
- );
123
- resp.setCookie(AuthService.mintSession(userData, 3600));
124
- resp.setCookie(AuthService.userCookie(userData, 3600));
125
- return resp;
126
- }
127
- }