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
@@ -1,68 +1,80 @@
1
- import { Method, Response, RouteContext } from 'toiljs/server/runtime';
1
+ import { Response, RouteContext } from 'toiljs/server/runtime';
2
2
  import { DataReader, DataWriter } from 'data';
3
3
 
4
+ import { encodeSessionUser } from './Session';
5
+
4
6
  /**
5
- * Post-quantum auth, illustrative. Shows how a tenant wires the no-import
6
- * `AuthService` global into a challenge-response login. ML-DSA-44 keypairs are
7
- * derived client-side from the password (Argon2id); the server only ever stores
8
- * and verifies PUBLIC material.
7
+ * Toil PQ-Auth: post-quantum password login, end-to-end and runnable under `toiljs dev`.
8
+ *
9
+ * The password never leaves the browser. The client blinds it through the
10
+ * server-keyed OPRF (precomputation-resistant keyed salt), stretches the OPRF
11
+ * output with Argon2id into an ML-DSA-44 keypair, and registers only the public
12
+ * key (+ a proof-of-possession). Login is a challenge-response that also runs an
13
+ * ML-KEM-768 key encapsulation: the server proves its identity by returning a
14
+ * confirmation tag only derivable from the decapsulated shared secret (mutual
15
+ * auth). See `server/globals/auth.ts` (the `AuthService` global) and the client
16
+ * half in `toiljs/client` (`Auth.register` / `Auth.login`).
9
17
  *
10
- * STORAGE IS THE APP'S, AND THIS EXAMPLE DOES NOT PROVIDE IT. A tenant's wasm
11
- * memory is wiped after every request, so the account record and the login
12
- * challenges CANNOT live in this module across the two round trips. A real
13
- * deployment must back `Accounts` and `Challenges` with an external store, and
14
- * the challenge "consume" MUST be a single atomic fetch-and-delete shared by
15
- * all instances (Redis `GETDEL`, or SQL `DELETE ... RETURNING`) -- never a
16
- * read-then-delete, or a sniffed signature replays across a race. The stubs
17
- * below throw to make that explicit; swap them for your store + a host KV/db
18
- * binding. The crypto and encoding (`AuthService`) are production-ready; the
19
- * orchestration here is a template.
18
+ * STORAGE: backed by the DEV-ONLY `kv.*` host imports (see
19
+ * `src/devserver/kv.ts`) so the register -> login chain spans requests under
20
+ * `toiljs dev`. REMOVE LATER: this is a stand-in; once toildb is implemented,
21
+ * the Accounts/Challenges stores move onto it and this goes away. `kv.*` is not
22
+ * on the production edge.
20
23
  *
21
24
  * Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
22
- * The client half lives in `toiljs/client` (`Auth.register` / `Auth.login`).
23
25
  */
24
26
 
25
27
  const AUD = 'toil-demo'; // this service's audience id (server config; never client-echoed)
26
- const MIN_MEM_KIB = 256 * 1024; // 256 MiB floor (KDF-params-as-credential)
27
- const MIN_ITERATIONS = 3;
28
28
 
29
- class AccountRecord {
30
- username: string = '';
31
- salt: Uint8Array = new Uint8Array(0);
32
- publicKey: Uint8Array = new Uint8Array(0);
33
- memKiB: u32 = 0;
34
- iterations: u32 = 0;
35
- parallelism: u32 = 0;
29
+ // Demo-light Argon2id params (responsive in a browser tab). A real deployment
30
+ // uses >= 256 MiB / >= 3 iterations. The client derives against whatever it is
31
+ // handed, so this is the single source of truth.
32
+ const DEMO_MEM_KIB: u32 = 32768; // 32 MiB
33
+ const DEMO_ITERS: u32 = 2;
34
+ const DEMO_PAR: u32 = 1;
35
+
36
+ const CHALLENGE_TTL_SECS: u64 = 120;
37
+ const SESSION_TTL_SECS: u64 = 3600;
38
+
39
+ // DEV fallback for the server ML-KEM-768 secret (decapsulation) key, hex, used
40
+ // only when `AUTH_KEM_SK` is not set in `.env.secrets`. Matches the PINNED public
41
+ // key in `src/client/auth.ts`. A real deployment sets AUTH_KEM_SK (and pins its
42
+ // own public key in the client) and rotates it; never ship a real one here.
43
+ const DEMO_KEM_SK_HEX: string = '3156a8eb11c62bdb4af9fc57bef470f880ae340373bcc61662748a9742a639b9ad6bc55a77a82e0caa99ede237b4783ce70ab08ecc5802a9478c4ca3de67acd7a2147db43fdba408e9765443f37e9e90cc09f836d53879b890126bd6c33d55a6d97636a28ba10e18ac919aa9d37c2e4d07b6c930a5cb3238c8338fbb1abe7dac124c93462ebc5ae81cb132947993a74f9602610eab68b7fc9407b58e958aca054443246240c484c650962408168632c303cfc738d3b918ee04a37c2436b6f7300b8c6e7bd528bc5c229673c3a1bc4ae4265772f654ed8377b285626c67a4ef715a5a04a56804c3fae93ca5e3219cd68649622ee0d77bcb664a68e377260a3a38c2739b81c3c9ec510b66acde5041f3b52922a17019dc9afaec71c3e3c3102686ceb019da138b22463ad7f452640526d1d8b21c9111ca844149d1391c937b84287f1a228342c06ccb87c31cb14227e175007c5c4497c11e8647377234a84ab2640aa8ee7acb54954f99155cf7d768446b104ac149f59ca1d0029401570db9341c93db0041d52fbbd62726a75f9ab177e4ea5176e675d28a1f9852c28b38074c91cec8064b6ba116db8b59c0434fbd1b207cd921fbf29b06740b53c7304b17b253652ad469b2cb10bf7ed3bcc5b1b6168c2d30a889f67a01ae79455100ac582ba2f764a4a4b134b9115d7c548032d55d4916ce25c0ce7c42160e446298fb10f747302e781a70b2b7962b0b54f3c0e3a4677e99cc02e41e66b0861d02d072b94ce3f8a04fd20d2ec220cea3737922808f00080186421e60b7d1076e5ca40099d54da33033021349e31bb65e12aa259b37bc975582aa6441ab2fabdc9cee0aab0c11c7e3489b93bab26e13bf399ab8a37949baba3c2f8a94fd97a9a551c96d582b5c1ba97b4547701656ee02567dd6a8362c1043c5874760c7d1133292f05c9d3689beccb903d4bd65f09e3e3255d0229daf9050ebaa107e51371fc9248393239575466a9c45b4a239e1b29b07d9701cf1bb488a95a004a98fcb1f6d548cc8554a3eb25a5fc90892618e5d33b04938567e748ab9ba79b0d39d611864b2140666c1791e79c5c0943a03038f7306551db3b271b08dec32443ae14674e16d6c42956ef36499348e7424bbc4883c37675a4f8bb28cd68f30b532ba80104e7214b9a4886045a152d161821a006ae03ae3742e36f63d997c858b850119e1004f4022a04a9533749d993641763a83dce5256f3826ae9b0584c72d69c77d6784444737a0192789e0d63a2f2808ce88b07c33383e588f68b13b892ac6998c9f2db14ba3e10eee4b9717761efc298e026974231a143b89009a724a7121292bb9292662b87502beadb9cbea3cc89de1997b376575f466b6693e18eb70630ba1823cae5f03698ae662190207156ca8d1a4a3cb926d20c92b524180c0804f057491c292024641bf9b21b52214bf2a2b42d16596e22935317bc712e64f64c143b257ca6f663223a1a2b6537b55746a2a739b2adbbfa004354a1555cc8b8215aa06413b27b7fa8c860386c13876b8d55b743860a13c0005dc4ac5e003cd3431c7a29edcc73c50b991e56a12423ac1f2842ed2999b7b31b6e01aaa83c01af658bae959b2cb256f1e7bba29d765e8083182891302569b3712a856e564fdd484b0706b0c68568d5ab7edc742cf74459d64595455a60f267973aa55e43c5be61925a3822eafcca445e36dc4655636e31e6fc9bec338b253f94290008ef7f40dbddb49c15c690f6755a23a1b3c85cfd5207e71a607086a6fc6d74a05080f43276901a19cafdb8de7771d58ea07f0f1056b905127b22223d08e75173199f13ab13c5dcd3b51ac784f84e520484a262b845a897c41cf27324ab6ba545c78c9ccab361051e0bba53498af26240fa0d566d1572684f4b42e253e6d052c848650915063c35641e1121ef8d9cfd17b667b351103c56d195007c9376d0c08aa268396814490eab4c364175a94533267a1933862cc4c33bcf0a13d1fa2b9d6c5082eeca1480672f2526cbe013beff14dc908a386e0b633c8761023cbed760deac6709bc328d865ac82e12307b673d96711dbb27a4d939230d25b53d594169a318be0200fa33550e9418e2a3b30e9719edc09d5fc4306f1abfd021eab14637a8a72c5931d25dc9b56db0e6ab677522b10f25307dbb804a6774ce05b87b0976a4b227bfe6caf20a79e64004fbd27b1eea018b3ab8ffa629f2dc87f19278f95168e94e44660a3370c537795678eb2f056260609769740583b51b291862927a1938737c6a37f40b78f00671cccbcb88ac3427b37915ed58782998f84051647707d48995472baad3f64a7cca54e1c0734db08751c614a34f28b84f2c1b5a6817355ab61957c486b7acffbc092bc8a7b46387f33b53ed372f7168d31a71cd008539928b0cdf91e835aa97f6a2be6d327b87a6ae478701d75a59a25179cb14997bb2552853014724170a1c49b82c2bcebc3279024e1fa44c53c7afdc43f0bd22116490f3b74c90e7296be58b9a91168f2fa0c3d378a3bcac959f357825c9976a8c9ee944f29b45e96d7345d9b478431a20cf1c5d3a3227c717fd204619777636c0cb140db5c50d2a3302334461030bee34e4eb1a6f02b733f9ccda4290fa168bc039568373241542728d00030d1f251e83737cb215adbdc1de75978675a0cd0d75b12748abdda7a9852629c63697d145af2c69854b06e03f37c4b064e4c9a4c03f2ad4d081e70180e9547247921918118086b62b4f7727f46b24e3e79ba3f28209f32b5102035bf935856232f83642268c0292ec6bf8e9462382163d30a20b4bcb7b4439310ec9d0a148193907fc07697342967cf1a16c6b3c71558951fa915400736cf699262b54b723abb2ecc27b74b68ee494287595ef818388adb49e883c67bfa5c226c0eef037a0851a29d34675912c1ea1068310b6dfcd017c809c8fbfc2c3ae78dfef07299960eeefba182662a90fa422c1790f356a2ea909012b15623a9b9e450a282cb530589a68368b3583159d9010ac3e52cc974753c342e58279516339dfb691df94b13a223ad97eb6a09c21dafe6304a3642d6d2067b5238497661fe88ad1227ca3557be2a576b6e17c5a7f997ea07929e76407e376aba74c44cd8504804776f39bbb8327624188a63501e83b404d9438cade0b11dc3ac61856447fb072b91761c228878f01b2eb6b4b21ba664c2c75882431603b25a449ffeb8410b910558581777562aa9b2181fd9c04713ad9326462d3e842121c4997f9aa932417c67851625816de66e0d65637434629f39d157cc40cbafccc4429c35caeda482299013baf565d0f38b8f2886b9641ae6bea5b2bfccd9e6f3000d1a2734414e5b6875828f9ca9b6c3d0ddeaf704111e2b38';
44
+
45
+
46
+ function utf8(s: string): Uint8Array {
47
+ return Uint8Array.wrap(String.UTF8.encode(s));
36
48
  }
37
49
 
38
- class ChallengeRecord {
39
- cid: Uint8Array = new Uint8Array(0);
40
- username: string = '';
41
- nonce: Uint8Array = new Uint8Array(0);
42
- iat: u64 = 0;
43
- exp: u64 = 0;
50
+ /** A secret from the env store (`Environment.getSecure`, backed by `.env.secrets`),
51
+ * falling back to a DEV default so the example runs with zero config. Set the
52
+ * real value in `.env.secrets` for any non-throwaway use. */
53
+ function envSecretOr(key: string, devDefault: string): string {
54
+ const v = Environment.getSecure(key);
55
+ return v != null ? v : devDefault;
44
56
  }
45
57
 
46
- // --- the storage the app MUST provide (external; these throw on purpose) -----
47
- namespace Accounts {
48
- export function get(_username: string): AccountRecord | null {
49
- throw new Error('wire Accounts to your store');
50
- }
51
- export function exists(_username: string): bool {
52
- throw new Error('wire Accounts to your store');
53
- }
54
- export function put(_a: AccountRecord): void {
55
- throw new Error('wire Accounts to your store');
58
+ function fromHex(s: string): Uint8Array {
59
+ const out = new Uint8Array(s.length >> 1);
60
+ for (let i = 0; i < out.length; i++) {
61
+ out[i] = <u8>(hexNibble(s.charCodeAt(i * 2)) * 16 + hexNibble(s.charCodeAt(i * 2 + 1)));
56
62
  }
63
+ return out;
57
64
  }
58
- namespace Challenges {
59
- export function put(_c: ChallengeRecord): void {
60
- throw new Error('wire Challenges to your store');
61
- }
62
- /** Atomic fetch-and-delete by cid (Redis GETDEL / SQL DELETE RETURNING). */
63
- export function consume(_cid: Uint8Array): ChallengeRecord | null {
64
- throw new Error('wire Challenges to an ATOMIC store');
65
+ function hexNibble(c: i32): i32 {
66
+ if (c >= 48 && c <= 57) return c - 48; // 0-9
67
+ if (c >= 97 && c <= 102) return c - 87; // a-f
68
+ if (c >= 65 && c <= 70) return c - 55; // A-F
69
+ return 0;
70
+ }
71
+ function toHex(b: Uint8Array): string {
72
+ let s = '';
73
+ for (let i = 0; i < b.length; i++) {
74
+ const v = b[i];
75
+ s += (v < 16 ? '0' : '') + (<u32>v).toString(16);
65
76
  }
77
+ return s;
66
78
  }
67
79
 
68
80
  function randomBytes(n: i32): Uint8Array {
@@ -71,114 +83,307 @@ function randomBytes(n: i32): Uint8Array {
71
83
  return b;
72
84
  }
73
85
 
86
+ function nowSecs(): u64 {
87
+ return <u64>(Date.now() / 1000);
88
+ }
89
+
90
+ /** One generic error on every failure path (anti-enumeration, anti-oracle). */
74
91
  function fail(): Response {
75
- // One generic error on every failure path (anti-enumeration, anti-oracle).
76
92
  return Response.text('auth: request failed\n', 401);
77
93
  }
78
94
 
95
+ /**
96
+ * Deterministic per-user Argon2id salt (16 bytes). With the OPRF providing
97
+ * precomputation resistance, a public/deterministic salt is fine: it only needs
98
+ * to be unique per user (the OPRF output already differs per user). Making it
99
+ * deterministic means register and login agree with NO stored salt, and an
100
+ * unknown user yields the SAME stable salt as a known one would -- no
101
+ * enumeration oracle.
102
+ */
103
+ function deriveSalt(username: string): Uint8Array {
104
+ return crypto.sha256Text('toil-demo-salt-v1:' + username).slice(0, 16);
105
+ }
106
+
107
+
108
+ let __configured = false;
109
+ /** Lazily configure the auth-route crypto material (OPRF seed + server ML-KEM
110
+ * key) on first use; only the register/login handlers below read these.
111
+ *
112
+ * The session HMAC secret is deliberately NOT set here. The session is verified
113
+ * by the `@auth` gate in `routes/Session` (a DIFFERENT route -> a different fresh
114
+ * wasm instance per request), so it must be configured for EVERY request, not
115
+ * just auth ones. That happens once, before routing, in `core/AppHandler`. */
116
+ function ensureConfigured(): void {
117
+ if (__configured) return;
118
+ // Both secrets come from the env store (`.env.secrets`), with DEV fallbacks
119
+ // so the example runs with zero config. Set the real values in `.env.secrets`
120
+ // (see `.env.secrets.example`) for any non-throwaway use.
121
+
122
+ // OPRF master seed: hashed to a 32-byte (RFC 9497 Ns) seed so any env value
123
+ // works. Per-user OPRF keys derive from this + the username.
124
+ AuthService.setOprfSeed(crypto.sha256Text(envSecretOr('AUTH_OPRF_SEED', 'toil-demo-oprf-seed-v1')));
125
+ // Server static ML-KEM secret key (must match the client's pinned public key).
126
+ const sk = fromHex(envSecretOr('AUTH_KEM_SK', DEMO_KEM_SK_HEX));
127
+ AuthService.setServerKemSecretKey(sk);
128
+ // The ML-KEM-768 public key (ek) is embedded in the decapsulation key at
129
+ // bytes [1152, 2336) (FIPS 203 dk layout); use it for the key id the login
130
+ // message binds. Identical to the public key the client pins.
131
+ AuthService.setServerKemPublicKey(sk.slice(1152, 2336));
132
+ __configured = true;
133
+ }
134
+
135
+
136
+ // @ts-ignore: decorator
137
+ @external('env', 'kv.put')
138
+ declare function __kvPut(keyPtr: usize, keyLen: i32, valPtr: usize, valLen: i32): void;
139
+ // @ts-ignore: decorator
140
+ @external('env', 'kv.get')
141
+ declare function __kvGet(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
142
+ // @ts-ignore: decorator
143
+ @external('env', 'kv.getdel')
144
+ declare function __kvGetDel(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
145
+
146
+ const KV_CAP: i32 = 8192; // bounds account (~1.5 KiB) + challenge (~100 B) records
147
+
148
+ function kvPut(key: Uint8Array, val: Uint8Array): void {
149
+ __kvPut(key.dataStart, key.length, val.dataStart, val.length);
150
+ }
151
+ function kvGet(key: Uint8Array): Uint8Array | null {
152
+ const out = new Uint8Array(KV_CAP);
153
+ const n = __kvGet(key.dataStart, key.length, out.dataStart, KV_CAP);
154
+ if (n < 0) return null;
155
+ return out.slice(0, n);
156
+ }
157
+ /** Atomic fetch-and-delete: consumes a login challenge exactly once. */
158
+ function kvGetDel(key: Uint8Array): Uint8Array | null {
159
+ const out = new Uint8Array(KV_CAP);
160
+ const n = __kvGetDel(key.dataStart, key.length, out.dataStart, KV_CAP);
161
+ if (n < 0) return null;
162
+ return out.slice(0, n);
163
+ }
164
+
165
+ function acctKey(username: string): Uint8Array {
166
+ return utf8('acct:' + username);
167
+ }
168
+ function chalKey(cid: Uint8Array): Uint8Array {
169
+ return utf8('chal:' + toHex(cid));
170
+ }
171
+
172
+
173
+ class Account {
174
+ username: string = '';
175
+ salt: Uint8Array = new Uint8Array(0);
176
+ publicKey: Uint8Array = new Uint8Array(0);
177
+ memKiB: u32 = 0;
178
+ iterations: u32 = 0;
179
+ parallelism: u32 = 0;
180
+ }
181
+
182
+ function putAccount(a: Account): void {
183
+ const w = new DataWriter();
184
+ w.writeString(a.username);
185
+ w.writeBytes(a.salt);
186
+ w.writeBytes(a.publicKey);
187
+ w.writeU32(a.memKiB);
188
+ w.writeU32(a.iterations);
189
+ w.writeU32(a.parallelism);
190
+ kvPut(acctKey(a.username), w.toBytes());
191
+ }
192
+ function getAccount(username: string): Account | null {
193
+ const raw = kvGet(acctKey(username));
194
+ if (raw == null) return null;
195
+ const r = new DataReader(raw);
196
+ const a = new Account();
197
+ a.username = r.readString();
198
+ a.salt = r.readBytes();
199
+ a.publicKey = r.readBytes();
200
+ a.memKiB = r.readU32();
201
+ a.iterations = r.readU32();
202
+ a.parallelism = r.readU32();
203
+ return r.ok ? a : null;
204
+ }
205
+
206
+ class Challenge {
207
+ cid: Uint8Array = new Uint8Array(0);
208
+ username: string = '';
209
+ nonce: Uint8Array = new Uint8Array(0);
210
+ iat: u64 = 0;
211
+ exp: u64 = 0;
212
+ }
213
+
214
+ function putChallenge(c: Challenge): void {
215
+ const w = new DataWriter();
216
+ w.writeBytes(c.cid);
217
+ w.writeString(c.username);
218
+ w.writeBytes(c.nonce);
219
+ w.writeU64(c.iat);
220
+ w.writeU64(c.exp);
221
+ kvPut(chalKey(c.cid), w.toBytes());
222
+ }
223
+ function consumeChallenge(cid: Uint8Array): Challenge | null {
224
+ const raw = kvGetDel(chalKey(cid));
225
+ if (raw == null) return null;
226
+ const r = new DataReader(raw);
227
+ const c = new Challenge();
228
+ c.cid = r.readBytes();
229
+ c.username = r.readString();
230
+ c.nonce = r.readBytes();
231
+ c.iat = r.readU64();
232
+ c.exp = r.readU64();
233
+ return r.ok ? c : null;
234
+ }
235
+
79
236
  @rest('auth')
80
237
  class Auth {
81
- /** POST /auth/register/start body: str(username)
82
- * resp: u8(status=0) + u32(mem) u32(iters) u32(par) bytes(salt) */
238
+ /** POST /auth/register/start body: str(username) bytes(blinded)
239
+ * resp: u8(status=0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)
240
+ * No taken-oracle: always succeeds; register/finish rejects a duplicate. */
83
241
  @post('/register/start')
84
242
  public registerStart(ctx: RouteContext): Response {
85
- const username = new DataReader(ctx.request.body).readString();
86
- if (Accounts.exists(username)) {
87
- return new Response(200, new DataWriter().writeU8(1).toBytes().slice(0)); // taken
88
- }
89
- const salt = randomBytes(16);
243
+ ensureConfigured();
244
+ const r = new DataReader(ctx.request.body);
245
+ const username = r.readString();
246
+ const blinded = r.readBytes();
247
+ if (!r.ok) return fail();
248
+ const evaluated = AuthService.oprfEvaluate(username, blinded);
249
+ if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
250
+
90
251
  const w = new DataWriter();
91
252
  w.writeU8(0);
92
- w.writeU32(<u32>MIN_MEM_KIB);
93
- w.writeU32(<u32>MIN_ITERATIONS);
94
- w.writeU32(1);
95
- w.writeBytes(salt);
96
- // NOTE: the salt must be persisted with the pending registration so
97
- // registerFinish stores the same one; omitted here (no store).
253
+ w.writeU32(DEMO_MEM_KIB);
254
+ w.writeU32(DEMO_ITERS);
255
+ w.writeU32(DEMO_PAR);
256
+ w.writeBytes(deriveSalt(username));
257
+ w.writeBytes(evaluated);
98
258
  return Response.bytes(w.toBytes());
99
259
  }
100
260
 
101
- /** POST /auth/register/finish body: str(username) bytes(pk) resp: u8(status) */
261
+ /** POST /auth/register/finish body: str(username) bytes(pk) bytes(regProof)
262
+ * resp: u8(status) -- 0 = ok, 1 = username already registered. Verifies
263
+ * proof-of-possession before storing the key. */
102
264
  @post('/register/finish')
103
265
  public registerFinish(ctx: RouteContext): Response {
266
+ ensureConfigured();
104
267
  const r = new DataReader(ctx.request.body);
105
268
  const username = r.readString();
106
269
  const pk = r.readBytes();
107
- if (Accounts.exists(username) || pk.length != 1312) return fail(); // ML-DSA-44 pk
108
- const a = new AccountRecord();
270
+ const proof = r.readBytes();
271
+ if (!r.ok) return fail();
272
+ if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
273
+ // Already registered: a distinguishable status (not the generic 401) so the
274
+ // client can say "username taken, log in instead" rather than a blank error.
275
+ if (getAccount(username) != null) {
276
+ return Response.bytes(new DataWriter().writeU8(1).toBytes());
277
+ }
278
+
279
+ // Proof-of-possession: the client signed buildRegisterMessage with the
280
+ // matching secret key, so we confirm it actually holds it.
281
+ const regMsg = AuthService.buildRegisterMessage(username, pk);
282
+ if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
283
+
284
+ const a = new Account();
109
285
  a.username = username;
286
+ a.salt = deriveSalt(username);
110
287
  a.publicKey = pk;
111
- a.memKiB = <u32>MIN_MEM_KIB;
112
- a.iterations = <u32>MIN_ITERATIONS;
113
- a.parallelism = 1;
114
- // a.salt = <the salt issued in registerStart>;
115
- Accounts.put(a);
288
+ a.memKiB = DEMO_MEM_KIB;
289
+ a.iterations = DEMO_ITERS;
290
+ a.parallelism = DEMO_PAR;
291
+ putAccount(a);
116
292
  return Response.bytes(new DataWriter().writeU8(0).toBytes());
117
293
  }
118
294
 
119
- /** POST /auth/login/start body: str(username)
120
- * resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt) bytes(nonce) u64(iat) u64(exp) */
295
+ /** POST /auth/login/start body: str(username) bytes(blinded)
296
+ * resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt)
297
+ * bytes(nonce) u64(iat) u64(exp) bytes(evaluated)
298
+ * Anti-enumeration: ALWAYS OPRF-evaluates (real or decoy key from the same
299
+ * seed+username), returns a deterministic per-user salt + constant params,
300
+ * and a fresh challenge -- a known and an unknown user are indistinguishable. */
121
301
  @post('/login/start')
122
302
  public loginStart(ctx: RouteContext): Response {
123
- const username = new DataReader(ctx.request.body).readString();
124
- const acct = Accounts.get(username);
303
+ ensureConfigured();
304
+ const r = new DataReader(ctx.request.body);
305
+ const username = r.readString();
306
+ const blinded = r.readBytes();
307
+ if (!r.ok) return fail();
308
+ const evaluated = AuthService.oprfEvaluate(username, blinded);
309
+ if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
125
310
 
311
+ const acct = getAccount(username);
126
312
  const cid = randomBytes(16);
127
313
  const nonce = randomBytes(32);
128
- const iat = <u64>(Date.now() / 1000);
129
- const exp = iat + 120;
130
-
131
- // Anti-enumeration: unknown user still gets a fully-formed challenge with
132
- // a throwaway salt; the eventual verify just fails.
133
- const salt = acct != null ? acct.salt : randomBytes(16);
134
- const mem = acct != null ? acct.memKiB : <u32>MIN_MEM_KIB;
135
- const iters = acct != null ? acct.iterations : <u32>MIN_ITERATIONS;
136
- const par = acct != null ? acct.parallelism : 1;
314
+ const iat = nowSecs();
315
+ const exp = iat + CHALLENGE_TTL_SECS;
137
316
 
317
+ // Persist only for a real account; the response is identical either way,
318
+ // and login/finish for an unknown user fails generically at consume.
138
319
  if (acct != null) {
139
- const c = new ChallengeRecord();
320
+ const c = new Challenge();
140
321
  c.cid = cid;
141
322
  c.username = username;
142
323
  c.nonce = nonce;
143
324
  c.iat = iat;
144
325
  c.exp = exp;
145
- Challenges.put(c);
326
+ putChallenge(c);
146
327
  }
147
328
 
148
329
  const w = new DataWriter();
149
330
  w.writeBytes(cid);
150
331
  w.writeString(AUD);
151
- w.writeU32(mem);
152
- w.writeU32(iters);
153
- w.writeU32(par);
154
- w.writeBytes(salt);
332
+ w.writeU32(DEMO_MEM_KIB);
333
+ w.writeU32(DEMO_ITERS);
334
+ w.writeU32(DEMO_PAR);
335
+ w.writeBytes(deriveSalt(username));
155
336
  w.writeBytes(nonce);
156
337
  w.writeU64(iat);
157
338
  w.writeU64(exp);
339
+ w.writeBytes(evaluated);
158
340
  return Response.bytes(w.toBytes());
159
341
  }
160
342
 
161
- /** POST /auth/login/finish body: bytes(cid) bytes(sig) resp: u8(status) [+ bytes(session)] */
343
+ /** POST /auth/login/finish body: bytes(cid) bytes(ct) bytes(sig)
344
+ * resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie */
162
345
  @post('/login/finish')
163
346
  public loginFinish(ctx: RouteContext): Response {
347
+ ensureConfigured();
164
348
  const r = new DataReader(ctx.request.body);
165
349
  const cid = r.readBytes();
350
+ const ct = r.readBytes();
166
351
  const sig = r.readBytes();
352
+ if (!r.ok) return fail();
167
353
 
168
354
  // 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
169
- const ch = Challenges.consume(cid);
355
+ const ch = consumeChallenge(cid);
170
356
  if (ch == null) return fail();
171
- const now = <u64>(Date.now() / 1000);
172
- if (now >= ch.exp) return fail();
357
+ if (nowSecs() >= ch.exp) return fail();
173
358
 
174
- // 2. Rebuild the message from OUR stored values (never client-echoed),
175
- // load the account's public key, verify under the login context.
176
- const acct = Accounts.get(ch.username);
359
+ // 2. Rebuild the message from OUR stored values + the client's ct (and
360
+ // the bound params + server key id), load the account key, verify.
361
+ const acct = getAccount(ch.username);
177
362
  if (acct == null) return fail();
178
- const message = AuthService.buildLoginMessage(ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp);
363
+ const message = AuthService.buildLoginMessage(
364
+ ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp,
365
+ ct, DEMO_MEM_KIB, DEMO_ITERS, DEMO_PAR, AuthService.serverKemKeyId(),
366
+ );
179
367
  if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
180
368
 
181
- // 3. Success: mint a session (cookie / token). App-specific.
182
- return Response.bytes(new DataWriter().writeU8(0).toBytes());
369
+ // 3. Decapsulate (proves WE hold the KEM key), derive the session key K
370
+ // bound to the transcript, and build the confirmation tag the client
371
+ // verifies for mutual auth.
372
+ const sharedSecret = AuthService.mlkemDecapsulate(ct);
373
+ if (sharedSecret.length != AuthService.SHARED_SECRET_LEN) return fail();
374
+ const transcriptHash = AuthService.sha256(message);
375
+ const sessionKey = AuthService.deriveSessionKey(sharedSecret, transcriptHash);
376
+ const confirm = AuthService.serverConfirmTag(sessionKey, transcriptHash);
377
+
378
+ // 4. Success: mint the session and return {0, sessionToken, confirm}.
379
+ const userData = encodeSessionUser(ch.username);
380
+ const w = new DataWriter();
381
+ w.writeU8(0);
382
+ w.writeBytes(userData); // opaque session token (the readable user payload)
383
+ w.writeBytes(confirm);
384
+ const resp = Response.bytes(w.toBytes());
385
+ resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
386
+ resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
387
+ return resp;
183
388
  }
184
389
  }
@@ -16,8 +16,11 @@ import { DataReader, DataWriter } from 'data';
16
16
  * this `/session/dev-login` mints one for a caller-named demo user so the flow is
17
17
  * runnable without the external account store the login example stubs out.
18
18
  *
19
- * The server secret defaults to a well-known DEV placeholder; a real deployment
20
- * calls `AuthService.setSecret(...)` once at startup (see server/main.ts).
19
+ * The session secret is configured once per request, BEFORE routing, in
20
+ * `core/AppHandler` (every request runs in a fresh wasm instance, so the secret
21
+ * must be set on each one, and the mint side and this `@auth` verify side are
22
+ * different routes). It reads `AUTH_SESSION_SECRET` from the env store with a
23
+ * clearly-insecure DEV fallback; a real deployment sets it in `.env.secrets`.
21
24
  */
22
25
 
23
26
  // @user: the authenticated-user shape. Exactly one per program.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.51",
4
+ "version": "0.0.53",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -118,6 +118,7 @@
118
118
  "dependencies": {
119
119
  "@dacely/hyper-express": "6.17.4",
120
120
  "@dacely/noble-post-quantum": "^0.6.1",
121
+ "@noble/curves": "^2.2.0",
121
122
  "@dacely/toilscript-loader": "^0.1.0",
122
123
  "@eslint-react/eslint-plugin": "^5.8.8",
123
124
  "@eslint/js": "^10.0.1",