toiljs 0.0.51 → 0.0.53

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/TYPESCRIPT_LAW.md +12601 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +16 -1
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/auth.d.ts +9 -20
  7. package/build/client/auth.js +112 -95
  8. package/build/client/index.d.ts +2 -2
  9. package/build/client/index.js +1 -1
  10. package/build/compiler/.tsbuildinfo +1 -1
  11. package/build/compiler/generate.js +1 -1
  12. package/build/devserver/.tsbuildinfo +1 -1
  13. package/build/devserver/crypto.js +33 -0
  14. package/build/devserver/host.js +2 -0
  15. package/build/devserver/kv.d.ts +3 -0
  16. package/build/devserver/kv.js +53 -0
  17. package/build/devserver/module.js +2 -1
  18. package/docs/auth-todo.md +149 -0
  19. package/docs/auth.md +234 -173
  20. package/examples/basic/client/routes/pq.tsx +72 -103
  21. package/examples/basic/server/core/AppHandler.ts +24 -3
  22. package/examples/basic/server/main.ts +0 -1
  23. package/examples/basic/server/routes/Auth.ts +304 -99
  24. package/examples/basic/server/routes/Session.ts +5 -2
  25. package/package.json +2 -1
  26. package/server/globals/auth.ts +263 -10
  27. package/src/cli/diagnostics.ts +22 -0
  28. package/src/cli/doctor.ts +2 -0
  29. package/src/client/auth.ts +192 -174
  30. package/src/client/index.ts +2 -2
  31. package/src/compiler/generate.ts +1 -1
  32. package/src/devserver/crypto.ts +54 -0
  33. package/src/devserver/host.ts +6 -0
  34. package/src/devserver/kv.ts +93 -0
  35. package/src/devserver/module.ts +4 -1
  36. package/test/devserver-pqauth.test.ts +153 -0
  37. package/test/doctor.test.ts +22 -0
  38. package/test/pqauth-e2e.test.ts +207 -0
  39. package/examples/basic/server/routes/PqDemo.ts +0 -127
@@ -0,0 +1,93 @@
1
+ /**
2
+ * DEV-ONLY persistent key-value store host imports (`env::kv.*`).
3
+ *
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.
9
+ *
10
+ * It is intentionally NOT registered on the production Rust edge
11
+ * (`toil-backend` `HOST_IMPORTS`), so a module using `kv.*` will not instantiate
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.
16
+ *
17
+ * Wire ABI (mirrors the crypto imports' caller-allocated-buffer convention):
18
+ * kv.put(keyPtr, keyLen, valPtr, valLen) -> void
19
+ * kv.get(keyPtr, keyLen, outPtr, outCap) -> i32 len | -1 absent | -2 too small
20
+ * kv.getdel(keyPtr, keyLen, outPtr, outCap) -> i32 len | -1 absent | -2 too small
21
+ * `getdel` is the atomic fetch-and-delete used to consume a login challenge
22
+ * exactly once; it deletes only on a successful read (never on a -2 probe).
23
+ */
24
+
25
+ import type { MemoryRef } from './host.js';
26
+
27
+ /** Process-lifetime store, shared across every dispatch (the whole point). */
28
+ const STORE = new Map<string, Buffer>();
29
+
30
+ /** Hard cap on a single value (bounds the dev process RAM). Account records are
31
+ * ~1.5 KiB (1312-byte ML-DSA key + salt + params); 64 KiB is generous. */
32
+ const MAX_VALUE_BYTES = 64 * 1024;
33
+ /** Hard cap on a key. */
34
+ const MAX_KEY_BYTES = 1024;
35
+
36
+ function mem(ref: MemoryRef): Buffer {
37
+ if (!ref.memory) throw new Error('kv host import called before memory was bound');
38
+ return Buffer.from(ref.memory.buffer);
39
+ }
40
+
41
+ function readBytes(ref: MemoryRef, ptr: number, len: number): Buffer {
42
+ const m = mem(ref);
43
+ if (ptr < 0 || len < 0 || ptr + len > m.length)
44
+ throw new Error(`kv read out of bounds: ptr=${String(ptr)} len=${String(len)}`);
45
+ return Buffer.from(m.subarray(ptr, ptr + len)); // copy out
46
+ }
47
+
48
+ /** Map key from raw guest bytes (latin1 is a lossless 1:1 byte<->char mapping). */
49
+ function keyOf(ref: MemoryRef, ptr: number, len: number): string {
50
+ if (len > MAX_KEY_BYTES) throw new Error(`kv key too long: ${String(len)} bytes`);
51
+ return readBytes(ref, ptr, len).toString('latin1');
52
+ }
53
+
54
+ /** Write a stored value into the caller buffer (if it fits) and return its
55
+ * length; -1 if absent, -2 if the value exceeds `outCap` (no write, no delete). */
56
+ function emit(ref: MemoryRef, value: Buffer | undefined, outPtr: number, outCap: number): number {
57
+ if (value === undefined) return -1;
58
+ if (value.length > outCap) return -2;
59
+ const m = mem(ref);
60
+ if (outPtr < 0 || outPtr + value.length > m.length)
61
+ throw new Error('kv get write out of bounds');
62
+ value.copy(m, outPtr);
63
+ return value.length;
64
+ }
65
+
66
+ export function buildKvImports(ref: MemoryRef): Record<string, (...args: number[]) => number | void> {
67
+ return {
68
+ 'kv.put': (keyPtr: number, keyLen: number, valPtr: number, valLen: number): void => {
69
+ if (valLen > MAX_VALUE_BYTES) throw new Error(`kv value too long: ${String(valLen)} bytes`);
70
+ STORE.set(keyOf(ref, keyPtr, keyLen), readBytes(ref, valPtr, valLen));
71
+ },
72
+
73
+ 'kv.get': (keyPtr: number, keyLen: number, outPtr: number, outCap: number): number => {
74
+ return emit(ref, STORE.get(keyOf(ref, keyPtr, keyLen)), outPtr, outCap);
75
+ },
76
+
77
+ // Atomic fetch-and-delete: deletes ONLY when the value is actually
78
+ // returned (a -2 "buffer too small" probe leaves the entry intact), so a
79
+ // login challenge is consumed exactly once.
80
+ 'kv.getdel': (keyPtr: number, keyLen: number, outPtr: number, outCap: number): number => {
81
+ const key = keyOf(ref, keyPtr, keyLen);
82
+ const value = STORE.get(key);
83
+ const n = emit(ref, value, outPtr, outCap);
84
+ if (n >= 0) STORE.delete(key);
85
+ return n;
86
+ },
87
+ };
88
+ }
89
+
90
+ /** Test-only: clear the store between unit tests. */
91
+ export function __resetKvForTests(): void {
92
+ STORE.clear();
93
+ }
@@ -58,7 +58,10 @@ const PROVIDED_IMPORTS = new Set([
58
58
  'crypto.fill_random', 'crypto.random_uuid', 'crypto.take_result', 'crypto.digest',
59
59
  'crypto.import_key', 'crypto.export_key', 'crypto.encrypt', 'crypto.decrypt',
60
60
  'crypto.sign', 'crypto.verify', 'crypto.derive_bits',
61
- 'crypto.mldsa_verify',
61
+ 'crypto.mldsa_verify', 'crypto.mlkem_decapsulate', 'crypto.voprf_evaluate',
62
+ // DEV-ONLY persistent KV (see ./kv.ts). REMOVE once the example is backed by
63
+ // a real external store; never ship as a production storage path.
64
+ 'kv.put', 'kv.get', 'kv.getdel',
62
65
  ]);
63
66
 
64
67
  export class WasmServerModule {
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Dev-server post-quantum auth host mocks: the ML-KEM-768 decapsulation and the
3
+ * RFC 9497 OPRF evaluation must be byte-identical to the production Rust edge
4
+ * (`toil-backend` fips203 / `voprf` crate), and the dev-only KV must behave like
5
+ * an atomic fetch-and-delete store. The OPRF mock is asserted against the same
6
+ * RFC 9497 Appendix A.1.1 vector the edge test uses — if both match the RFC,
7
+ * the noble client interops with both servers.
8
+ */
9
+ import { describe, expect, it, beforeEach } from 'vitest';
10
+
11
+ import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
12
+
13
+ import { buildCryptoImports, freshCryptoState } from '../src/devserver/crypto.js';
14
+ import { buildKvImports, __resetKvForTests } from '../src/devserver/kv.js';
15
+
16
+ type Ref = { memory: WebAssembly.Memory | null };
17
+
18
+ function freshMem(pages = 4): Ref {
19
+ return { memory: new WebAssembly.Memory({ initial: pages }) };
20
+ }
21
+ function buf(ref: Ref): Buffer {
22
+ return Buffer.from(ref.memory!.buffer);
23
+ }
24
+ function put(ref: Ref, ptr: number, bytes: Uint8Array): void {
25
+ buf(ref).set(bytes, ptr);
26
+ }
27
+ const h2b = (h: string): Uint8Array => Uint8Array.from(Buffer.from(h, 'hex'));
28
+ const b2h = (u: Uint8Array): string => Buffer.from(u).toString('hex');
29
+
30
+ describe('crypto.mlkem_decapsulate dev mock', () => {
31
+ it('recovers the shared secret a noble client encapsulated (wiring round-trip)', () => {
32
+ const ref = freshMem();
33
+ const imports = buildCryptoImports(ref, freshCryptoState());
34
+ const decap = imports['crypto.mlkem_decapsulate'];
35
+
36
+ const seed = new Uint8Array(64).fill(9);
37
+ const { publicKey, secretKey } = ml_kem768.keygen(seed);
38
+ const { cipherText, sharedSecret } = ml_kem768.encapsulate(publicKey);
39
+
40
+ const ctPtr = 0;
41
+ const skPtr = 4096;
42
+ const outPtr = 16384;
43
+ put(ref, ctPtr, cipherText); // 1088
44
+ put(ref, skPtr, secretKey); // 2400
45
+
46
+ const rc = decap(ctPtr, cipherText.length, skPtr, secretKey.length, outPtr);
47
+ expect(rc).toBe(0);
48
+ const got = buf(ref).subarray(outPtr, outPtr + 32);
49
+ expect(b2h(got)).toBe(b2h(sharedSecret));
50
+ });
51
+
52
+ it('rejects wrong sizes with -4', () => {
53
+ const ref = freshMem();
54
+ const decap = buildCryptoImports(ref, freshCryptoState())['crypto.mlkem_decapsulate'];
55
+ expect(decap(0, 1087, 4096, 2400, 16384)).toBe(-4);
56
+ expect(decap(0, 1088, 4096, 2399, 16384)).toBe(-4);
57
+ });
58
+ });
59
+
60
+ describe('crypto.voprf_evaluate dev mock (RFC 9497 A.1.1, ristretto255-SHA512)', () => {
61
+ // The interop gate: matching the RFC means the dev mock == the edge ==
62
+ // anything else RFC 9497-conformant (the noble client).
63
+ const SEED = 'a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3';
64
+ const KEY_INFO = '74657374206b6579'; // "test key"
65
+ const BLINDED = '609a0ae68c15a3cf6903766461307e5c8bb2f95e7e6550e1ffa2dc99e412803c';
66
+ const EVALUATED = '7ec6578ae5120958eb2db1745758ff379e77cb64fe77b0b2d8cc917ea0869c7e';
67
+
68
+ it('matches the RFC evaluated element', () => {
69
+ const ref = freshMem();
70
+ const evaluate = buildCryptoImports(ref, freshCryptoState())['crypto.voprf_evaluate'];
71
+
72
+ const seed = h2b(SEED);
73
+ const info = h2b(KEY_INFO);
74
+ const blinded = h2b(BLINDED);
75
+ const seedPtr = 0;
76
+ const infoPtr = 256;
77
+ const blindedPtr = 512;
78
+ const outPtr = 1024;
79
+ put(ref, seedPtr, seed);
80
+ put(ref, infoPtr, info);
81
+ put(ref, blindedPtr, blinded);
82
+
83
+ const rc = evaluate(seedPtr, seed.length, infoPtr, info.length, blindedPtr, blinded.length, outPtr);
84
+ expect(rc).toBe(0);
85
+ const got = buf(ref).subarray(outPtr, outPtr + 32);
86
+ expect(b2h(got)).toBe(EVALUATED);
87
+ });
88
+
89
+ it('rejects a bad blinded-element length with -4', () => {
90
+ const ref = freshMem();
91
+ const evaluate = buildCryptoImports(ref, freshCryptoState())['crypto.voprf_evaluate'];
92
+ expect(evaluate(0, 32, 256, 8, 512, 31, 1024)).toBe(-4);
93
+ });
94
+ });
95
+
96
+ describe('dev kv store', () => {
97
+ beforeEach(() => __resetKvForTests());
98
+
99
+ it('put then get round-trips a value', () => {
100
+ const ref = freshMem();
101
+ const kv = buildKvImports(ref);
102
+ const key = Buffer.from('acct:alice', 'latin1');
103
+ const val = Buffer.from([1, 2, 3, 4, 5]);
104
+ put(ref, 0, key);
105
+ put(ref, 64, val);
106
+ kv['kv.put'](0, key.length, 64, val.length);
107
+
108
+ const outPtr = 256;
109
+ const n = kv['kv.get'](0, key.length, outPtr, 1024) as number;
110
+ expect(n).toBe(val.length);
111
+ expect(b2h(buf(ref).subarray(outPtr, outPtr + n))).toBe(b2h(val));
112
+ });
113
+
114
+ it('get returns -1 for an absent key, -2 when the buffer is too small', () => {
115
+ const ref = freshMem();
116
+ const kv = buildKvImports(ref);
117
+ const key = Buffer.from('chal:missing', 'latin1');
118
+ put(ref, 0, key);
119
+ expect(kv['kv.get'](0, key.length, 256, 1024)).toBe(-1);
120
+
121
+ const val = Buffer.alloc(100, 7);
122
+ put(ref, 64, val);
123
+ kv['kv.put'](0, key.length, 64, val.length);
124
+ expect(kv['kv.get'](0, key.length, 256, 10)).toBe(-2); // too small, no write
125
+ });
126
+
127
+ it('getdel returns the value once then deletes it (atomic consume)', () => {
128
+ const ref = freshMem();
129
+ const kv = buildKvImports(ref);
130
+ const key = Buffer.from('chal:cid', 'latin1');
131
+ const val = Buffer.from([9, 8, 7]);
132
+ put(ref, 0, key);
133
+ put(ref, 64, val);
134
+ kv['kv.put'](0, key.length, 64, val.length);
135
+
136
+ expect(kv['kv.getdel'](0, key.length, 256, 1024)).toBe(val.length);
137
+ // Second consume finds nothing — the replay-prevention property.
138
+ expect(kv['kv.getdel'](0, key.length, 256, 1024)).toBe(-1);
139
+ });
140
+
141
+ it('getdel does NOT delete on a -2 probe', () => {
142
+ const ref = freshMem();
143
+ const kv = buildKvImports(ref);
144
+ const key = Buffer.from('chal:probe', 'latin1');
145
+ const val = Buffer.alloc(50, 3);
146
+ put(ref, 0, key);
147
+ put(ref, 64, val);
148
+ kv['kv.put'](0, key.length, 64, val.length);
149
+
150
+ expect(kv['kv.getdel'](0, key.length, 256, 10)).toBe(-2); // probe, too small
151
+ expect(kv['kv.getdel'](0, key.length, 256, 1024)).toBe(val.length); // still there
152
+ });
153
+ });
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
 
3
3
  import {
4
4
  checkBasePath,
5
+ checkDevScripts,
5
6
  checkDuplicatePatterns,
6
7
  type CheckGroup,
7
8
  checkMountSlots,
@@ -203,3 +204,24 @@ describe('summarize', () => {
203
204
  expect(summarize(groups)).toEqual({ pass: 1, warn: 1, fail: 1 });
204
205
  });
205
206
  });
207
+
208
+ describe('checkDevScripts', () => {
209
+ it('passes when no script wraps toiljs in npx', () => {
210
+ const c = checkDevScripts({ dev: 'toiljs dev', build: 'toiljs build' });
211
+ expect(c.status).toBe('pass');
212
+ });
213
+
214
+ it('warns and names the offending scripts using npx toiljs', () => {
215
+ const c = checkDevScripts({ dev: 'npx toiljs dev', build: 'toiljs build' });
216
+ expect(c.status).toBe('warn');
217
+ expect(c.detail).toContain('dev');
218
+ expect(c.detail).not.toContain('build');
219
+ expect(c.fix).toMatch(/toiljs <cmd>/);
220
+ });
221
+
222
+ it('does not flag unrelated npx usage or substrings', () => {
223
+ expect(checkDevScripts({ x: 'npx create-toiljs my-app' }).status).toBe('pass');
224
+ expect(checkDevScripts({ x: 'echo npxtoiljs' }).status).toBe('pass');
225
+ expect(checkDevScripts({}).status).toBe('pass');
226
+ });
227
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * End-to-end post-quantum auth: drives the REAL browser client (`src/client/auth.ts`
3
+ * — OPRF blind/finalize, Argon2id, ML-DSA keygen/sign, ML-KEM encapsulate,
4
+ * mutual-auth confirm) against the toilscript-compiled example server wasm
5
+ * (`examples/basic` Auth route + the AuthService global), through the dev-server
6
+ * host imports (OPRF + ML-KEM mocks + the dev KV). A `fetch` shim routes the
7
+ * client's requests into `WasmServerModule.dispatch`, and the in-process dev KV
8
+ * persists across dispatches so register -> login spans "requests".
9
+ *
10
+ * This is the full chain interop gate: if the noble client and the
11
+ * voprf/fips203-equivalent dev mocks + the AS AuthService disagree on a single
12
+ * byte, register or login fails here.
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
19
+
20
+ import { ristretto255_oprf } from '@noble/curves/ed25519.js';
21
+
22
+ import { WasmServerModule } from '../src/devserver/index.js';
23
+ import { __resetKvForTests } from '../src/devserver/kv.js';
24
+ import { Auth } from '../src/client/auth.js';
25
+ import { DataReader, DataWriter } from '../src/io/codec.js';
26
+
27
+ const EXAMPLE_WASM = path.resolve(
28
+ path.dirname(fileURLToPath(import.meta.url)),
29
+ '../examples/basic/build/server/release.wasm',
30
+ );
31
+
32
+ const haveWasm = fs.existsSync(EXAMPLE_WASM);
33
+
34
+ function loadModule(): WasmServerModule {
35
+ const m = new WasmServerModule(EXAMPLE_WASM);
36
+ m.refresh();
37
+ return m;
38
+ }
39
+
40
+ /** Route the client's `fetch(path, {body})` into the dev wasm dispatcher. */
41
+ function installFetchShim(m: WasmServerModule): () => void {
42
+ const original = globalThis.fetch;
43
+ const jar = new Map<string, string>();
44
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
45
+ const url = typeof input === 'string' ? input : input.toString();
46
+ const pathname = new URL(url, 'http://localhost').pathname;
47
+ const bodyBytes =
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('; ')]);
55
+ const r = m.dispatch({
56
+ method: (init?.method ?? 'GET') as 'GET' | 'POST',
57
+ path: pathname,
58
+ headers,
59
+ body: bodyBytes,
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
+ }
69
+ const ab = r.body.buffer.slice(r.body.byteOffset, r.body.byteOffset + r.body.byteLength);
70
+ return {
71
+ ok: r.status >= 200 && r.status < 300,
72
+ status: r.status,
73
+ arrayBuffer: async () => ab,
74
+ text: async () => Buffer.from(r.body).toString('utf8'),
75
+ } as Response;
76
+ }) as typeof fetch;
77
+ return () => {
78
+ globalThis.fetch = original;
79
+ };
80
+ }
81
+
82
+ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example wasm)', () => {
83
+ let restoreFetch: () => void;
84
+ let mod: WasmServerModule;
85
+
86
+ beforeEach(() => {
87
+ __resetKvForTests();
88
+ mod = loadModule();
89
+ restoreFetch = installFetchShim(mod);
90
+ });
91
+ afterEach(() => restoreFetch());
92
+
93
+ it(
94
+ 'registers then logs in (full OPRF + ML-DSA + ML-KEM mutual-auth chain)',
95
+ async () => {
96
+ await Auth.register('ada', 'correct horse battery staple');
97
+ // login resolves ONLY if the server's mutual-auth confirmation tag
98
+ // verified against the client's own shared secret.
99
+ const session = await Auth.login('ada', 'correct horse battery staple');
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);
121
+ },
122
+ 60_000,
123
+ );
124
+
125
+ it(
126
+ 'rejects a wrong password at login',
127
+ async () => {
128
+ await Auth.register('bob', 'hunter2-correct');
129
+ await expect(Auth.login('bob', 'hunter2-WRONG')).rejects.toThrow(/login failed|request failed/);
130
+ },
131
+ 60_000,
132
+ );
133
+
134
+ it(
135
+ 'rejects login for a never-registered user',
136
+ async () => {
137
+ await expect(Auth.login('ghost', 'whatever')).rejects.toThrow(/login failed|request failed/);
138
+ },
139
+ 60_000,
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
+ );
154
+ });
155
+
156
+ // Lower-level wire checks that don't need the heavy Argon2id derivation.
157
+ describe.skipIf(!haveWasm)('post-quantum auth wire-level (anti-enumeration, replay)', () => {
158
+ beforeEach(() => __resetKvForTests());
159
+
160
+ const oprf = ristretto255_oprf.oprf;
161
+ const loginStart = (m: WasmServerModule, username: string) => {
162
+ const { blinded } = oprf.blind(new TextEncoder().encode('pw'));
163
+ const body = new DataWriter().writeString(username).writeBytes(blinded).toBytes();
164
+ const r = m.dispatch({
165
+ method: 'POST',
166
+ path: '/auth/login/start',
167
+ headers: [['host', 'localhost:3000'], ['content-type', 'application/octet-stream']],
168
+ body,
169
+ });
170
+ expect(r.status).toBe(200);
171
+ const rd = new DataReader(r.body);
172
+ const cid = rd.readBytes();
173
+ const aud = rd.readString();
174
+ const mem = rd.readU32();
175
+ const iters = rd.readU32();
176
+ const par = rd.readU32();
177
+ const salt = rd.readBytes();
178
+ const nonce = rd.readBytes();
179
+ const iat = rd.readU64();
180
+ const exp = rd.readU64();
181
+ const evaluated = rd.readBytes();
182
+ return { cid, aud, mem, iters, par, salt, nonce, iat, exp, evaluated };
183
+ };
184
+ const hex = (u: Uint8Array) => Buffer.from(u).toString('hex');
185
+
186
+ it('returns a STABLE per-user salt for an unknown user across calls (no enumeration)', () => {
187
+ const m = loadModule();
188
+ const a = loginStart(m, 'no-such-user');
189
+ const b = loginStart(m, 'no-such-user');
190
+ // The original bug returned fresh random salt each call for unknown users.
191
+ expect(hex(a.salt)).toBe(hex(b.salt));
192
+ // Shape is fully formed and identical-looking to a real user's response.
193
+ expect(a.salt.length).toBe(16);
194
+ expect(a.nonce.length).toBe(32);
195
+ expect(a.evaluated.length).toBe(32);
196
+ expect(a.aud).toBe('toil-demo');
197
+ // The randomized fields DO differ (fresh challenge each call).
198
+ expect(hex(a.cid)).not.toBe(hex(b.cid));
199
+ });
200
+
201
+ it('two unknown users get different (per-user) salts', () => {
202
+ const m = loadModule();
203
+ const a = loginStart(m, 'alpha-unknown');
204
+ const b = loginStart(m, 'beta-unknown');
205
+ expect(hex(a.salt)).not.toBe(hex(b.salt));
206
+ });
207
+ });
@@ -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
- }