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.
- package/CHANGELOG.md +18 -0
- package/TYPESCRIPT_LAW.md +12601 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +16 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +8 -8
- package/build/client/auth.js +105 -24
- package/build/client/index.d.ts +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +1 -1
- package/build/compiler/index.js +7 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +33 -0
- package/build/devserver/host.js +2 -0
- package/build/devserver/kv.d.ts +3 -0
- package/build/devserver/kv.js +53 -0
- package/build/devserver/module.js +2 -1
- package/examples/basic/client/routes/pq.tsx +67 -104
- package/examples/basic/server/routes/Auth.ts +274 -100
- package/package.json +2 -1
- package/server/globals/auth.ts +215 -0
- package/src/cli/diagnostics.ts +22 -0
- package/src/cli/doctor.ts +2 -0
- package/src/client/auth.ts +179 -51
- package/src/client/index.ts +1 -1
- package/src/compiler/generate.ts +1 -1
- package/src/compiler/index.ts +14 -2
- package/src/devserver/crypto.ts +54 -0
- package/src/devserver/host.ts +6 -0
- package/src/devserver/kv.ts +96 -0
- package/src/devserver/module.ts +4 -1
- package/test/devserver-pqauth.test.ts +153 -0
- package/test/doctor.test.ts +22 -0
- package/test/pqauth-e2e.test.ts +162 -0
package/src/devserver/crypto.ts
CHANGED
|
@@ -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
|
|
package/src/devserver/host.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/devserver/module.ts
CHANGED
|
@@ -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
|
+
});
|
package/test/doctor.test.ts
CHANGED
|
@@ -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
|
+
});
|