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.
- package/CHANGELOG.md +19 -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 +9 -20
- package/build/client/auth.js +112 -95
- package/build/client/index.d.ts +2 -2
- package/build/client/index.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +1 -1
- 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/docs/auth-todo.md +149 -0
- package/docs/auth.md +234 -173
- package/examples/basic/client/routes/pq.tsx +72 -103
- package/examples/basic/server/core/AppHandler.ts +24 -3
- package/examples/basic/server/main.ts +0 -1
- package/examples/basic/server/routes/Auth.ts +304 -99
- package/examples/basic/server/routes/Session.ts +5 -2
- package/package.json +2 -1
- package/server/globals/auth.ts +263 -10
- package/src/cli/diagnostics.ts +22 -0
- package/src/cli/doctor.ts +2 -0
- package/src/client/auth.ts +192 -174
- package/src/client/index.ts +2 -2
- package/src/compiler/generate.ts +1 -1
- package/src/devserver/crypto.ts +54 -0
- package/src/devserver/host.ts +6 -0
- package/src/devserver/kv.ts +93 -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 +207 -0
- 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
|
+
}
|
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,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
|
-
}
|