toiljs 0.0.50 → 0.0.52

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.
@@ -18,6 +18,8 @@
18
18
  import * as nodeCrypto from 'node:crypto';
19
19
 
20
20
  import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
21
+ import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
22
+ import { ristretto255_oprf } from '@noble/curves/ed25519.js';
21
23
 
22
24
  import type { MemoryRef } from './host.js';
23
25
 
@@ -234,6 +236,58 @@ export function buildCryptoImports(
234
236
  return -1;
235
237
  }
236
238
  },
239
+
240
+ // ML-KEM-768 (FIPS 203) decapsulation for the mutual-auth + session-key
241
+ // layer. Mirrors the edge host (`mlkem_decapsulate_import.rs` / fips203):
242
+ // recover the 32-byte shared secret from the client's ciphertext using
243
+ // the server's static secret key, write it to `outPtr`, return 0 / neg.
244
+ // Backed by the same noble lib the client encapsulates with (dev == prod).
245
+ 'crypto.mlkem_decapsulate': (
246
+ ctPtr: number, ctLen: number,
247
+ skPtr: number, skLen: number,
248
+ outPtr: number,
249
+ ): number => {
250
+ if (ctLen !== 1088 || skLen !== 2400) return -4;
251
+ try {
252
+ const ct = new Uint8Array(readBytes(ref, ctPtr, ctLen));
253
+ const sk = new Uint8Array(readBytes(ref, skPtr, skLen));
254
+ const ss = ml_kem768.decapsulate(ct, sk); // 32 bytes; implicit rejection on bad ct
255
+ writeBytes(ref, outPtr, Buffer.from(ss));
256
+ return 0;
257
+ } catch {
258
+ return -5;
259
+ }
260
+ },
261
+
262
+ // RFC 9497 OPRF (mode 0x00, ristretto255-SHA512) server evaluation for
263
+ // the OPAQUE-style keyed salt. Mirrors the edge host
264
+ // (`voprf_evaluate_import.rs` / the `voprf` crate): derive the per-user
265
+ // key from (seed, info=username) and blind-evaluate the client's blinded
266
+ // element, writing the 32-byte evaluated element to `outPtr`. Backed by
267
+ // `@noble/curves` ristretto255_oprf, which matches the edge byte-for-byte
268
+ // (both RFC 9497), so dev == prod.
269
+ 'crypto.voprf_evaluate': (
270
+ seedPtr: number, seedLen: number,
271
+ infoPtr: number, infoLen: number,
272
+ blindedPtr: number, blindedLen: number,
273
+ outPtr: number,
274
+ ): number => {
275
+ // seedLen MUST be exactly 32 (RFC 9497 Ns; noble deriveKeyPair rejects
276
+ // other lengths) -- matching the edge so dev and prod never diverge.
277
+ if (blindedLen !== 32 || seedLen !== 32 || infoLen > 512) return -4;
278
+ try {
279
+ const seed = new Uint8Array(readBytes(ref, seedPtr, seedLen));
280
+ const info = new Uint8Array(readBytes(ref, infoPtr, infoLen));
281
+ const blinded = new Uint8Array(readBytes(ref, blindedPtr, blindedLen));
282
+ const oprf = ristretto255_oprf.oprf;
283
+ const kp = oprf.deriveKeyPair(seed, info);
284
+ const evaluated = oprf.blindEvaluate(kp.secretKey, blinded); // 32-byte element
285
+ writeBytes(ref, outPtr, Buffer.from(evaluated));
286
+ return 0;
287
+ } catch {
288
+ return -5;
289
+ }
290
+ },
237
291
  };
238
292
  }
239
293
 
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { buildCryptoImports, freshCryptoState, type CryptoState } from './crypto.js';
21
+ import { buildKvImports } from './kv.js';
21
22
  import { EmailStatus, getEmailService } from './email/index.js';
22
23
  import { parseEmailBlob } from './email/wire.js';
23
24
  import { devEnvGet, devEnvGetSecure } from './env.js';
@@ -268,6 +269,11 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
268
269
  // Web Crypto host functions (`env.crypto.*`), backed by Node's
269
270
  // `crypto`. The dev server skips metering, so these charge nothing.
270
271
  ...buildCryptoImports(ref, state.crypto),
272
+
273
+ // DEV-ONLY persistent KV (`env.kv.*`). REMOVE LATER — scaffolding so
274
+ // the auth example's register/login chain spans requests under
275
+ // `toiljs dev`; not present on the production edge (see ./kv.ts).
276
+ ...buildKvImports(ref),
271
277
  },
272
278
  };
273
279
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * DEV-ONLY persistent key-value store host imports (`env::kv.*`).
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.
10
+ *
11
+ * It is intentionally NOT registered on the production Rust edge
12
+ * (`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
+ * ========================================================================
19
+ *
20
+ * Wire ABI (mirrors the crypto imports' caller-allocated-buffer convention):
21
+ * kv.put(keyPtr, keyLen, valPtr, valLen) -> void
22
+ * kv.get(keyPtr, keyLen, outPtr, outCap) -> i32 len | -1 absent | -2 too small
23
+ * kv.getdel(keyPtr, keyLen, outPtr, outCap) -> i32 len | -1 absent | -2 too small
24
+ * `getdel` is the atomic fetch-and-delete used to consume a login challenge
25
+ * exactly once; it deletes only on a successful read (never on a -2 probe).
26
+ */
27
+
28
+ import type { MemoryRef } from './host.js';
29
+
30
+ /** Process-lifetime store, shared across every dispatch (the whole point). */
31
+ const STORE = new Map<string, Buffer>();
32
+
33
+ /** Hard cap on a single value (bounds the dev process RAM). Account records are
34
+ * ~1.5 KiB (1312-byte ML-DSA key + salt + params); 64 KiB is generous. */
35
+ const MAX_VALUE_BYTES = 64 * 1024;
36
+ /** Hard cap on a key. */
37
+ const MAX_KEY_BYTES = 1024;
38
+
39
+ function mem(ref: MemoryRef): Buffer {
40
+ if (!ref.memory) throw new Error('kv host import called before memory was bound');
41
+ return Buffer.from(ref.memory.buffer);
42
+ }
43
+
44
+ function readBytes(ref: MemoryRef, ptr: number, len: number): Buffer {
45
+ const m = mem(ref);
46
+ if (ptr < 0 || len < 0 || ptr + len > m.length)
47
+ throw new Error(`kv read out of bounds: ptr=${String(ptr)} len=${String(len)}`);
48
+ return Buffer.from(m.subarray(ptr, ptr + len)); // copy out
49
+ }
50
+
51
+ /** Map key from raw guest bytes (latin1 is a lossless 1:1 byte<->char mapping). */
52
+ function keyOf(ref: MemoryRef, ptr: number, len: number): string {
53
+ if (len > MAX_KEY_BYTES) throw new Error(`kv key too long: ${String(len)} bytes`);
54
+ return readBytes(ref, ptr, len).toString('latin1');
55
+ }
56
+
57
+ /** Write a stored value into the caller buffer (if it fits) and return its
58
+ * length; -1 if absent, -2 if the value exceeds `outCap` (no write, no delete). */
59
+ function emit(ref: MemoryRef, value: Buffer | undefined, outPtr: number, outCap: number): number {
60
+ if (value === undefined) return -1;
61
+ if (value.length > outCap) return -2;
62
+ const m = mem(ref);
63
+ if (outPtr < 0 || outPtr + value.length > m.length)
64
+ throw new Error('kv get write out of bounds');
65
+ value.copy(m, outPtr);
66
+ return value.length;
67
+ }
68
+
69
+ export function buildKvImports(ref: MemoryRef): Record<string, (...args: number[]) => number | void> {
70
+ return {
71
+ 'kv.put': (keyPtr: number, keyLen: number, valPtr: number, valLen: number): void => {
72
+ if (valLen > MAX_VALUE_BYTES) throw new Error(`kv value too long: ${String(valLen)} bytes`);
73
+ STORE.set(keyOf(ref, keyPtr, keyLen), readBytes(ref, valPtr, valLen));
74
+ },
75
+
76
+ 'kv.get': (keyPtr: number, keyLen: number, outPtr: number, outCap: number): number => {
77
+ return emit(ref, STORE.get(keyOf(ref, keyPtr, keyLen)), outPtr, outCap);
78
+ },
79
+
80
+ // Atomic fetch-and-delete: deletes ONLY when the value is actually
81
+ // returned (a -2 "buffer too small" probe leaves the entry intact), so a
82
+ // login challenge is consumed exactly once.
83
+ 'kv.getdel': (keyPtr: number, keyLen: number, outPtr: number, outCap: number): number => {
84
+ const key = keyOf(ref, keyPtr, keyLen);
85
+ const value = STORE.get(key);
86
+ const n = emit(ref, value, outPtr, outCap);
87
+ if (n >= 0) STORE.delete(key);
88
+ return n;
89
+ },
90
+ };
91
+ }
92
+
93
+ /** Test-only: clear the store between unit tests. */
94
+ export function __resetKvForTests(): void {
95
+ STORE.clear();
96
+ }
@@ -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,162 @@
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
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
44
+ const url = typeof input === 'string' ? input : input.toString();
45
+ const pathname = new URL(url, 'http://localhost').pathname;
46
+ const bodyBytes =
47
+ init?.body == null ? new Uint8Array(0) : new Uint8Array(init.body as ArrayBuffer);
48
+ const r = m.dispatch({
49
+ method: (init?.method ?? 'GET') as 'GET' | 'POST',
50
+ path: pathname,
51
+ headers: [
52
+ ['host', 'localhost:3000'],
53
+ ['content-type', 'application/octet-stream'],
54
+ ],
55
+ body: bodyBytes,
56
+ });
57
+ const ab = r.body.buffer.slice(r.body.byteOffset, r.body.byteOffset + r.body.byteLength);
58
+ return {
59
+ ok: r.status >= 200 && r.status < 300,
60
+ status: r.status,
61
+ arrayBuffer: async () => ab,
62
+ text: async () => Buffer.from(r.body).toString('utf8'),
63
+ } as Response;
64
+ }) as typeof fetch;
65
+ return () => {
66
+ globalThis.fetch = original;
67
+ };
68
+ }
69
+
70
+ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example wasm)', () => {
71
+ let restoreFetch: () => void;
72
+ let mod: WasmServerModule;
73
+
74
+ beforeEach(() => {
75
+ __resetKvForTests();
76
+ mod = loadModule();
77
+ restoreFetch = installFetchShim(mod);
78
+ });
79
+ afterEach(() => restoreFetch());
80
+
81
+ it(
82
+ 'registers then logs in (full OPRF + ML-DSA + ML-KEM mutual-auth chain)',
83
+ async () => {
84
+ await Auth.register('ada', 'correct horse battery staple');
85
+ // login resolves ONLY if the server's mutual-auth confirmation tag
86
+ // verified against the client's own shared secret.
87
+ const session = await Auth.login('ada', 'correct horse battery staple');
88
+ expect(session.length).toBeGreaterThan(0);
89
+ },
90
+ 60_000,
91
+ );
92
+
93
+ it(
94
+ 'rejects a wrong password at login',
95
+ async () => {
96
+ await Auth.register('bob', 'hunter2-correct');
97
+ await expect(Auth.login('bob', 'hunter2-WRONG')).rejects.toThrow(/login failed|request failed/);
98
+ },
99
+ 60_000,
100
+ );
101
+
102
+ it(
103
+ 'rejects login for a never-registered user',
104
+ async () => {
105
+ await expect(Auth.login('ghost', 'whatever')).rejects.toThrow(/login failed|request failed/);
106
+ },
107
+ 60_000,
108
+ );
109
+ });
110
+
111
+ // Lower-level wire checks that don't need the heavy Argon2id derivation.
112
+ describe.skipIf(!haveWasm)('post-quantum auth wire-level (anti-enumeration, replay)', () => {
113
+ beforeEach(() => __resetKvForTests());
114
+
115
+ const oprf = ristretto255_oprf.oprf;
116
+ const loginStart = (m: WasmServerModule, username: string) => {
117
+ const { blinded } = oprf.blind(new TextEncoder().encode('pw'));
118
+ const body = new DataWriter().writeString(username).writeBytes(blinded).toBytes();
119
+ const r = m.dispatch({
120
+ method: 'POST',
121
+ path: '/auth/login/start',
122
+ headers: [['host', 'localhost:3000'], ['content-type', 'application/octet-stream']],
123
+ body,
124
+ });
125
+ expect(r.status).toBe(200);
126
+ const rd = new DataReader(r.body);
127
+ const cid = rd.readBytes();
128
+ const aud = rd.readString();
129
+ const mem = rd.readU32();
130
+ const iters = rd.readU32();
131
+ const par = rd.readU32();
132
+ const salt = rd.readBytes();
133
+ const nonce = rd.readBytes();
134
+ const iat = rd.readU64();
135
+ const exp = rd.readU64();
136
+ const evaluated = rd.readBytes();
137
+ return { cid, aud, mem, iters, par, salt, nonce, iat, exp, evaluated };
138
+ };
139
+ const hex = (u: Uint8Array) => Buffer.from(u).toString('hex');
140
+
141
+ it('returns a STABLE per-user salt for an unknown user across calls (no enumeration)', () => {
142
+ const m = loadModule();
143
+ const a = loginStart(m, 'no-such-user');
144
+ const b = loginStart(m, 'no-such-user');
145
+ // The original bug returned fresh random salt each call for unknown users.
146
+ expect(hex(a.salt)).toBe(hex(b.salt));
147
+ // Shape is fully formed and identical-looking to a real user's response.
148
+ expect(a.salt.length).toBe(16);
149
+ expect(a.nonce.length).toBe(32);
150
+ expect(a.evaluated.length).toBe(32);
151
+ expect(a.aud).toBe('toil-demo');
152
+ // The randomized fields DO differ (fresh challenge each call).
153
+ expect(hex(a.cid)).not.toBe(hex(b.cid));
154
+ });
155
+
156
+ it('two unknown users get different (per-user) salts', () => {
157
+ const m = loadModule();
158
+ const a = loginStart(m, 'alpha-unknown');
159
+ const b = loginStart(m, 'beta-unknown');
160
+ expect(hex(a.salt)).not.toBe(hex(b.salt));
161
+ });
162
+ });