toiljs 0.0.51 → 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.
@@ -1,68 +1,72 @@
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
+ * OPAQUE-style post-quantum auth, 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 KV LATER ============ this is a stand-in;
21
+ * once toildb is implemented, the Accounts/Challenges stores move onto it and
22
+ * this goes away. `kv.*` is not 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;
36
- }
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;
37
35
 
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;
36
+ const CHALLENGE_TTL_SECS: u64 = 120;
37
+ const SESSION_TTL_SECS: u64 = 3600;
38
+
39
+ // DEV server ML-KEM-768 secret (decapsulation) key, hex. Matches the PINNED
40
+ // public key in `src/client/auth.ts`. DEV ONLY: a real deployment loads this
41
+ // from the secure env store and rotates it; never commit a real one.
42
+ const SERVER_KEM_SK_HEX: string = '3156a8eb11c62bdb4af9fc57bef470f880ae340373bcc61662748a9742a639b9ad6bc55a77a82e0caa99ede237b4783ce70ab08ecc5802a9478c4ca3de67acd7a2147db43fdba408e9765443f37e9e90cc09f836d53879b890126bd6c33d55a6d97636a28ba10e18ac919aa9d37c2e4d07b6c930a5cb3238c8338fbb1abe7dac124c93462ebc5ae81cb132947993a74f9602610eab68b7fc9407b58e958aca054443246240c484c650962408168632c303cfc738d3b918ee04a37c2436b6f7300b8c6e7bd528bc5c229673c3a1bc4ae4265772f654ed8377b285626c67a4ef715a5a04a56804c3fae93ca5e3219cd68649622ee0d77bcb664a68e377260a3a38c2739b81c3c9ec510b66acde5041f3b52922a17019dc9afaec71c3e3c3102686ceb019da138b22463ad7f452640526d1d8b21c9111ca844149d1391c937b84287f1a228342c06ccb87c31cb14227e175007c5c4497c11e8647377234a84ab2640aa8ee7acb54954f99155cf7d768446b104ac149f59ca1d0029401570db9341c93db0041d52fbbd62726a75f9ab177e4ea5176e675d28a1f9852c28b38074c91cec8064b6ba116db8b59c0434fbd1b207cd921fbf29b06740b53c7304b17b253652ad469b2cb10bf7ed3bcc5b1b6168c2d30a889f67a01ae79455100ac582ba2f764a4a4b134b9115d7c548032d55d4916ce25c0ce7c42160e446298fb10f747302e781a70b2b7962b0b54f3c0e3a4677e99cc02e41e66b0861d02d072b94ce3f8a04fd20d2ec220cea3737922808f00080186421e60b7d1076e5ca40099d54da33033021349e31bb65e12aa259b37bc975582aa6441ab2fabdc9cee0aab0c11c7e3489b93bab26e13bf399ab8a37949baba3c2f8a94fd97a9a551c96d582b5c1ba97b4547701656ee02567dd6a8362c1043c5874760c7d1133292f05c9d3689beccb903d4bd65f09e3e3255d0229daf9050ebaa107e51371fc9248393239575466a9c45b4a239e1b29b07d9701cf1bb488a95a004a98fcb1f6d548cc8554a3eb25a5fc90892618e5d33b04938567e748ab9ba79b0d39d611864b2140666c1791e79c5c0943a03038f7306551db3b271b08dec32443ae14674e16d6c42956ef36499348e7424bbc4883c37675a4f8bb28cd68f30b532ba80104e7214b9a4886045a152d161821a006ae03ae3742e36f63d997c858b850119e1004f4022a04a9533749d993641763a83dce5256f3826ae9b0584c72d69c77d6784444737a0192789e0d63a2f2808ce88b07c33383e588f68b13b892ac6998c9f2db14ba3e10eee4b9717761efc298e026974231a143b89009a724a7121292bb9292662b87502beadb9cbea3cc89de1997b376575f466b6693e18eb70630ba1823cae5f03698ae662190207156ca8d1a4a3cb926d20c92b524180c0804f057491c292024641bf9b21b52214bf2a2b42d16596e22935317bc712e64f64c143b257ca6f663223a1a2b6537b55746a2a739b2adbbfa004354a1555cc8b8215aa06413b27b7fa8c860386c13876b8d55b743860a13c0005dc4ac5e003cd3431c7a29edcc73c50b991e56a12423ac1f2842ed2999b7b31b6e01aaa83c01af658bae959b2cb256f1e7bba29d765e8083182891302569b3712a856e564fdd484b0706b0c68568d5ab7edc742cf74459d64595455a60f267973aa55e43c5be61925a3822eafcca445e36dc4655636e31e6fc9bec338b253f94290008ef7f40dbddb49c15c690f6755a23a1b3c85cfd5207e71a607086a6fc6d74a05080f43276901a19cafdb8de7771d58ea07f0f1056b905127b22223d08e75173199f13ab13c5dcd3b51ac784f84e520484a262b845a897c41cf27324ab6ba545c78c9ccab361051e0bba53498af26240fa0d566d1572684f4b42e253e6d052c848650915063c35641e1121ef8d9cfd17b667b351103c56d195007c9376d0c08aa268396814490eab4c364175a94533267a1933862cc4c33bcf0a13d1fa2b9d6c5082eeca1480672f2526cbe013beff14dc908a386e0b633c8761023cbed760deac6709bc328d865ac82e12307b673d96711dbb27a4d939230d25b53d594169a318be0200fa33550e9418e2a3b30e9719edc09d5fc4306f1abfd021eab14637a8a72c5931d25dc9b56db0e6ab677522b10f25307dbb804a6774ce05b87b0976a4b227bfe6caf20a79e64004fbd27b1eea018b3ab8ffa629f2dc87f19278f95168e94e44660a3370c537795678eb2f056260609769740583b51b291862927a1938737c6a37f40b78f00671cccbcb88ac3427b37915ed58782998f84051647707d48995472baad3f64a7cca54e1c0734db08751c614a34f28b84f2c1b5a6817355ab61957c486b7acffbc092bc8a7b46387f33b53ed372f7168d31a71cd008539928b0cdf91e835aa97f6a2be6d327b87a6ae478701d75a59a25179cb14997bb2552853014724170a1c49b82c2bcebc3279024e1fa44c53c7afdc43f0bd22116490f3b74c90e7296be58b9a91168f2fa0c3d378a3bcac959f357825c9976a8c9ee944f29b45e96d7345d9b478431a20cf1c5d3a3227c717fd204619777636c0cb140db5c50d2a3302334461030bee34e4eb1a6f02b733f9ccda4290fa168bc039568373241542728d00030d1f251e83737cb215adbdc1de75978675a0cd0d75b12748abdda7a9852629c63697d145af2c69854b06e03f37c4b064e4c9a4c03f2ad4d081e70180e9547247921918118086b62b4f7727f46b24e3e79ba3f28209f32b5102035bf935856232f83642268c0292ec6bf8e9462382163d30a20b4bcb7b4439310ec9d0a148193907fc07697342967cf1a16c6b3c71558951fa915400736cf699262b54b723abb2ecc27b74b68ee494287595ef818388adb49e883c67bfa5c226c0eef037a0851a29d34675912c1ea1068310b6dfcd017c809c8fbfc2c3ae78dfef07299960eeefba182662a90fa422c1790f356a2ea909012b15623a9b9e450a282cb530589a68368b3583159d9010ac3e52cc974753c342e58279516339dfb691df94b13a223ad97eb6a09c21dafe6304a3642d6d2067b5238497661fe88ad1227ca3557be2a576b6e17c5a7f997ea07929e76407e376aba74c44cd8504804776f39bbb8327624188a63501e83b404d9438cade0b11dc3ac61856447fb072b91761c228878f01b2eb6b4b21ba664c2c75882431603b25a449ffeb8410b910558581777562aa9b2181fd9c04713ad9326462d3e842121c4997f9aa932417c67851625816de66e0d65637434629f39d157cc40cbafccc4429c35caeda482299013baf565d0f38b8f2886b9641ae6bea5b2bfccd9e6f3000d1a2734414e5b6875828f9ca9b6c3d0ddeaf704111e2b38';
43
+
44
+ // --- small helpers ----------------------------------------------------------
45
+
46
+ function utf8(s: string): Uint8Array {
47
+ return Uint8Array.wrap(String.UTF8.encode(s));
44
48
  }
45
49
 
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');
50
+ function fromHex(s: string): Uint8Array {
51
+ const out = new Uint8Array(s.length >> 1);
52
+ for (let i = 0; i < out.length; i++) {
53
+ out[i] = <u8>(hexNibble(s.charCodeAt(i * 2)) * 16 + hexNibble(s.charCodeAt(i * 2 + 1)));
56
54
  }
55
+ return out;
57
56
  }
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');
57
+ function hexNibble(c: i32): i32 {
58
+ if (c >= 48 && c <= 57) return c - 48; // 0-9
59
+ if (c >= 97 && c <= 102) return c - 87; // a-f
60
+ if (c >= 65 && c <= 70) return c - 55; // A-F
61
+ return 0;
62
+ }
63
+ function toHex(b: Uint8Array): string {
64
+ let s = '';
65
+ for (let i = 0; i < b.length; i++) {
66
+ const v = b[i];
67
+ s += (v < 16 ? '0' : '') + (<u32>v).toString(16);
65
68
  }
69
+ return s;
66
70
  }
67
71
 
68
72
  function randomBytes(n: i32): Uint8Array {
@@ -71,114 +75,284 @@ function randomBytes(n: i32): Uint8Array {
71
75
  return b;
72
76
  }
73
77
 
78
+ function nowSecs(): u64 {
79
+ return <u64>(Date.now() / 1000);
80
+ }
81
+
82
+ /** One generic error on every failure path (anti-enumeration, anti-oracle). */
74
83
  function fail(): Response {
75
- // One generic error on every failure path (anti-enumeration, anti-oracle).
76
84
  return Response.text('auth: request failed\n', 401);
77
85
  }
78
86
 
87
+ /**
88
+ * Deterministic per-user Argon2id salt (16 bytes). With the OPRF providing
89
+ * precomputation resistance, a public/deterministic salt is fine: it only needs
90
+ * to be unique per user (the OPRF output already differs per user). Making it
91
+ * deterministic means register and login agree with NO stored salt, and an
92
+ * unknown user yields the SAME stable salt as a known one would -- no
93
+ * enumeration oracle.
94
+ */
95
+ function deriveSalt(username: string): Uint8Array {
96
+ return crypto.sha256Text('toil-demo-salt-v1:' + username).slice(0, 16);
97
+ }
98
+
99
+ // --- startup config (idempotent; re-applied per fresh instance) -------------
100
+
101
+ let __configured = false;
102
+ function ensureConfigured(): void {
103
+ if (__configured) return;
104
+ // OPRF master seed: 32 bytes from a fixed label (a real deployment uses a
105
+ // secret from the env store). Per-user OPRF keys derive from this + username.
106
+ AuthService.setOprfSeed(crypto.sha256Text('toil-demo-oprf-seed-v1'));
107
+ // Server static ML-KEM secret key (matches the client's pinned public key).
108
+ AuthService.setServerKemSecretKey(fromHex(SERVER_KEM_SK_HEX));
109
+ __configured = true;
110
+ }
111
+
112
+ // --- KV-backed storage (DEV-ONLY host imports; see src/devserver/kv.ts) ------
113
+
114
+ // @ts-ignore: decorator
115
+ @external('env', 'kv.put')
116
+ declare function __kvPut(keyPtr: usize, keyLen: i32, valPtr: usize, valLen: i32): void;
117
+ // @ts-ignore: decorator
118
+ @external('env', 'kv.get')
119
+ declare function __kvGet(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
120
+ // @ts-ignore: decorator
121
+ @external('env', 'kv.getdel')
122
+ declare function __kvGetDel(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
123
+
124
+ const KV_CAP: i32 = 8192; // bounds account (~1.5 KiB) + challenge (~100 B) records
125
+
126
+ function kvPut(key: Uint8Array, val: Uint8Array): void {
127
+ __kvPut(key.dataStart, key.length, val.dataStart, val.length);
128
+ }
129
+ function kvGet(key: Uint8Array): Uint8Array | null {
130
+ const out = new Uint8Array(KV_CAP);
131
+ const n = __kvGet(key.dataStart, key.length, out.dataStart, KV_CAP);
132
+ if (n < 0) return null;
133
+ return out.slice(0, n);
134
+ }
135
+ /** Atomic fetch-and-delete: consumes a login challenge exactly once. */
136
+ function kvGetDel(key: Uint8Array): Uint8Array | null {
137
+ const out = new Uint8Array(KV_CAP);
138
+ const n = __kvGetDel(key.dataStart, key.length, out.dataStart, KV_CAP);
139
+ if (n < 0) return null;
140
+ return out.slice(0, n);
141
+ }
142
+
143
+ function acctKey(username: string): Uint8Array {
144
+ return utf8('acct:' + username);
145
+ }
146
+ function chalKey(cid: Uint8Array): Uint8Array {
147
+ return utf8('chal:' + toHex(cid));
148
+ }
149
+
150
+ // --- records ----------------------------------------------------------------
151
+
152
+ class Account {
153
+ username: string = '';
154
+ salt: Uint8Array = new Uint8Array(0);
155
+ publicKey: Uint8Array = new Uint8Array(0);
156
+ memKiB: u32 = 0;
157
+ iterations: u32 = 0;
158
+ parallelism: u32 = 0;
159
+ }
160
+
161
+ function putAccount(a: Account): void {
162
+ const w = new DataWriter();
163
+ w.writeString(a.username);
164
+ w.writeBytes(a.salt);
165
+ w.writeBytes(a.publicKey);
166
+ w.writeU32(a.memKiB);
167
+ w.writeU32(a.iterations);
168
+ w.writeU32(a.parallelism);
169
+ kvPut(acctKey(a.username), w.toBytes());
170
+ }
171
+ function getAccount(username: string): Account | null {
172
+ const raw = kvGet(acctKey(username));
173
+ if (raw == null) return null;
174
+ const r = new DataReader(raw);
175
+ const a = new Account();
176
+ a.username = r.readString();
177
+ a.salt = r.readBytes();
178
+ a.publicKey = r.readBytes();
179
+ a.memKiB = r.readU32();
180
+ a.iterations = r.readU32();
181
+ a.parallelism = r.readU32();
182
+ return r.ok ? a : null;
183
+ }
184
+
185
+ class Challenge {
186
+ cid: Uint8Array = new Uint8Array(0);
187
+ username: string = '';
188
+ nonce: Uint8Array = new Uint8Array(0);
189
+ iat: u64 = 0;
190
+ exp: u64 = 0;
191
+ }
192
+
193
+ function putChallenge(c: Challenge): void {
194
+ const w = new DataWriter();
195
+ w.writeBytes(c.cid);
196
+ w.writeString(c.username);
197
+ w.writeBytes(c.nonce);
198
+ w.writeU64(c.iat);
199
+ w.writeU64(c.exp);
200
+ kvPut(chalKey(c.cid), w.toBytes());
201
+ }
202
+ function consumeChallenge(cid: Uint8Array): Challenge | null {
203
+ const raw = kvGetDel(chalKey(cid));
204
+ if (raw == null) return null;
205
+ const r = new DataReader(raw);
206
+ const c = new Challenge();
207
+ c.cid = r.readBytes();
208
+ c.username = r.readString();
209
+ c.nonce = r.readBytes();
210
+ c.iat = r.readU64();
211
+ c.exp = r.readU64();
212
+ return r.ok ? c : null;
213
+ }
214
+
79
215
  @rest('auth')
80
216
  class Auth {
81
- /** POST /auth/register/start body: str(username)
82
- * resp: u8(status=0) + u32(mem) u32(iters) u32(par) bytes(salt) */
217
+ /** POST /auth/register/start body: str(username) bytes(blinded)
218
+ * resp: u8(status=0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)
219
+ * No taken-oracle: always succeeds; register/finish rejects a duplicate. */
83
220
  @post('/register/start')
84
221
  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);
222
+ ensureConfigured();
223
+ const r = new DataReader(ctx.request.body);
224
+ const username = r.readString();
225
+ const blinded = r.readBytes();
226
+ if (!r.ok) return fail();
227
+ const evaluated = AuthService.oprfEvaluate(username, blinded);
228
+ if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
229
+
90
230
  const w = new DataWriter();
91
231
  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).
232
+ w.writeU32(DEMO_MEM_KIB);
233
+ w.writeU32(DEMO_ITERS);
234
+ w.writeU32(DEMO_PAR);
235
+ w.writeBytes(deriveSalt(username));
236
+ w.writeBytes(evaluated);
98
237
  return Response.bytes(w.toBytes());
99
238
  }
100
239
 
101
- /** POST /auth/register/finish body: str(username) bytes(pk) resp: u8(status) */
240
+ /** POST /auth/register/finish body: str(username) bytes(pk) bytes(regProof)
241
+ * resp: u8(status). Verifies proof-of-possession before storing the key. */
102
242
  @post('/register/finish')
103
243
  public registerFinish(ctx: RouteContext): Response {
244
+ ensureConfigured();
104
245
  const r = new DataReader(ctx.request.body);
105
246
  const username = r.readString();
106
247
  const pk = r.readBytes();
107
- if (Accounts.exists(username) || pk.length != 1312) return fail(); // ML-DSA-44 pk
108
- const a = new AccountRecord();
248
+ const proof = r.readBytes();
249
+ if (!r.ok) return fail();
250
+ if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
251
+ if (getAccount(username) != null) return fail(); // already registered
252
+
253
+ // Proof-of-possession: the client signed buildRegisterMessage with the
254
+ // matching secret key, so we confirm it actually holds it.
255
+ const regMsg = AuthService.buildRegisterMessage(username, pk);
256
+ if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
257
+
258
+ const a = new Account();
109
259
  a.username = username;
260
+ a.salt = deriveSalt(username);
110
261
  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);
262
+ a.memKiB = DEMO_MEM_KIB;
263
+ a.iterations = DEMO_ITERS;
264
+ a.parallelism = DEMO_PAR;
265
+ putAccount(a);
116
266
  return Response.bytes(new DataWriter().writeU8(0).toBytes());
117
267
  }
118
268
 
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) */
269
+ /** POST /auth/login/start body: str(username) bytes(blinded)
270
+ * resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt)
271
+ * bytes(nonce) u64(iat) u64(exp) bytes(evaluated)
272
+ * Anti-enumeration: ALWAYS OPRF-evaluates (real or decoy key from the same
273
+ * seed+username), returns a deterministic per-user salt + constant params,
274
+ * and a fresh challenge -- a known and an unknown user are indistinguishable. */
121
275
  @post('/login/start')
122
276
  public loginStart(ctx: RouteContext): Response {
123
- const username = new DataReader(ctx.request.body).readString();
124
- const acct = Accounts.get(username);
277
+ ensureConfigured();
278
+ const r = new DataReader(ctx.request.body);
279
+ const username = r.readString();
280
+ const blinded = r.readBytes();
281
+ if (!r.ok) return fail();
282
+ const evaluated = AuthService.oprfEvaluate(username, blinded);
283
+ if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
125
284
 
285
+ const acct = getAccount(username);
126
286
  const cid = randomBytes(16);
127
287
  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;
288
+ const iat = nowSecs();
289
+ const exp = iat + CHALLENGE_TTL_SECS;
137
290
 
291
+ // Persist only for a real account; the response is identical either way,
292
+ // and login/finish for an unknown user fails generically at consume.
138
293
  if (acct != null) {
139
- const c = new ChallengeRecord();
294
+ const c = new Challenge();
140
295
  c.cid = cid;
141
296
  c.username = username;
142
297
  c.nonce = nonce;
143
298
  c.iat = iat;
144
299
  c.exp = exp;
145
- Challenges.put(c);
300
+ putChallenge(c);
146
301
  }
147
302
 
148
303
  const w = new DataWriter();
149
304
  w.writeBytes(cid);
150
305
  w.writeString(AUD);
151
- w.writeU32(mem);
152
- w.writeU32(iters);
153
- w.writeU32(par);
154
- w.writeBytes(salt);
306
+ w.writeU32(DEMO_MEM_KIB);
307
+ w.writeU32(DEMO_ITERS);
308
+ w.writeU32(DEMO_PAR);
309
+ w.writeBytes(deriveSalt(username));
155
310
  w.writeBytes(nonce);
156
311
  w.writeU64(iat);
157
312
  w.writeU64(exp);
313
+ w.writeBytes(evaluated);
158
314
  return Response.bytes(w.toBytes());
159
315
  }
160
316
 
161
- /** POST /auth/login/finish body: bytes(cid) bytes(sig) resp: u8(status) [+ bytes(session)] */
317
+ /** POST /auth/login/finish body: bytes(cid) bytes(ct) bytes(sig)
318
+ * resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie */
162
319
  @post('/login/finish')
163
320
  public loginFinish(ctx: RouteContext): Response {
321
+ ensureConfigured();
164
322
  const r = new DataReader(ctx.request.body);
165
323
  const cid = r.readBytes();
324
+ const ct = r.readBytes();
166
325
  const sig = r.readBytes();
326
+ if (!r.ok) return fail();
167
327
 
168
328
  // 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
169
- const ch = Challenges.consume(cid);
329
+ const ch = consumeChallenge(cid);
170
330
  if (ch == null) return fail();
171
- const now = <u64>(Date.now() / 1000);
172
- if (now >= ch.exp) return fail();
331
+ if (nowSecs() >= ch.exp) return fail();
173
332
 
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);
333
+ // 2. Rebuild the v2 message from OUR stored values + the client's ct,
334
+ // load the account key, verify the login signature.
335
+ const acct = getAccount(ch.username);
177
336
  if (acct == null) return fail();
178
- const message = AuthService.buildLoginMessage(ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp);
337
+ const message = AuthService.buildLoginMessageV2(ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp, ct);
179
338
  if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
180
339
 
181
- // 3. Success: mint a session (cookie / token). App-specific.
182
- return Response.bytes(new DataWriter().writeU8(0).toBytes());
340
+ // 3. Decapsulate the client's ciphertext (proves WE hold the KEM key) and
341
+ // build the mutual-auth confirmation tag the client will verify.
342
+ const sharedSecret = AuthService.mlkemDecapsulate(ct);
343
+ if (sharedSecret.length != AuthService.SHARED_SECRET_LEN) return fail();
344
+ const transcriptHash = AuthService.sha256(message);
345
+ const confirm = AuthService.serverConfirmTag(sharedSecret, transcriptHash);
346
+
347
+ // 4. Success: mint the session and return {0, sessionToken, confirm}.
348
+ const userData = encodeSessionUser(ch.username);
349
+ const w = new DataWriter();
350
+ w.writeU8(0);
351
+ w.writeBytes(userData); // opaque session token (the readable user payload)
352
+ w.writeBytes(confirm);
353
+ const resp = Response.bytes(w.toBytes());
354
+ resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
355
+ resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
356
+ return resp;
183
357
  }
184
358
  }
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.52",
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",