toiljs 0.0.52 → 0.0.54

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.
@@ -4,7 +4,7 @@ import { DataReader, DataWriter } from 'data';
4
4
  import { encodeSessionUser } from './Session';
5
5
 
6
6
  /**
7
- * OPAQUE-style post-quantum auth, end-to-end and runnable under `toiljs dev`.
7
+ * Toil PQ-Auth: post-quantum password login, end-to-end and runnable under `toiljs dev`.
8
8
  *
9
9
  * The password never leaves the browser. The client blinds it through the
10
10
  * server-keyed OPRF (precomputation-resistant keyed salt), stretches the OPRF
@@ -17,9 +17,9 @@ import { encodeSessionUser } from './Session';
17
17
  *
18
18
  * STORAGE: backed by the DEV-ONLY `kv.*` host imports (see
19
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
+ * `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.
23
23
  *
24
24
  * Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
25
25
  */
@@ -36,30 +36,10 @@ const DEMO_PAR: u32 = 1;
36
36
  const CHALLENGE_TTL_SECS: u64 = 120;
37
37
  const SESSION_TTL_SECS: u64 = 3600;
38
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
39
  function utf8(s: string): Uint8Array {
47
40
  return Uint8Array.wrap(String.UTF8.encode(s));
48
41
  }
49
42
 
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)));
54
- }
55
- return out;
56
- }
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
43
  function toHex(b: Uint8Array): string {
64
44
  let s = '';
65
45
  for (let i = 0; i < b.length; i++) {
@@ -96,20 +76,6 @@ function deriveSalt(username: string): Uint8Array {
96
76
  return crypto.sha256Text('toil-demo-salt-v1:' + username).slice(0, 16);
97
77
  }
98
78
 
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
79
 
114
80
  // @ts-ignore: decorator
115
81
  @external('env', 'kv.put')
@@ -147,7 +113,6 @@ function chalKey(cid: Uint8Array): Uint8Array {
147
113
  return utf8('chal:' + toHex(cid));
148
114
  }
149
115
 
150
- // --- records ----------------------------------------------------------------
151
116
 
152
117
  class Account {
153
118
  username: string = '';
@@ -219,7 +184,6 @@ class Auth {
219
184
  * No taken-oracle: always succeeds; register/finish rejects a duplicate. */
220
185
  @post('/register/start')
221
186
  public registerStart(ctx: RouteContext): Response {
222
- ensureConfigured();
223
187
  const r = new DataReader(ctx.request.body);
224
188
  const username = r.readString();
225
189
  const blinded = r.readBytes();
@@ -238,17 +202,21 @@ class Auth {
238
202
  }
239
203
 
240
204
  /** POST /auth/register/finish body: str(username) bytes(pk) bytes(regProof)
241
- * resp: u8(status). Verifies proof-of-possession before storing the key. */
205
+ * resp: u8(status) -- 0 = ok, 1 = username already registered. Verifies
206
+ * proof-of-possession before storing the key. */
242
207
  @post('/register/finish')
243
208
  public registerFinish(ctx: RouteContext): Response {
244
- ensureConfigured();
245
209
  const r = new DataReader(ctx.request.body);
246
210
  const username = r.readString();
247
211
  const pk = r.readBytes();
248
212
  const proof = r.readBytes();
249
213
  if (!r.ok) return fail();
250
214
  if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
251
- if (getAccount(username) != null) return fail(); // already registered
215
+ // Already registered: a distinguishable status (not the generic 401) so the
216
+ // client can say "username taken, log in instead" rather than a blank error.
217
+ if (getAccount(username) != null) {
218
+ return Response.bytes(new DataWriter().writeU8(1).toBytes());
219
+ }
252
220
 
253
221
  // Proof-of-possession: the client signed buildRegisterMessage with the
254
222
  // matching secret key, so we confirm it actually holds it.
@@ -274,7 +242,6 @@ class Auth {
274
242
  * and a fresh challenge -- a known and an unknown user are indistinguishable. */
275
243
  @post('/login/start')
276
244
  public loginStart(ctx: RouteContext): Response {
277
- ensureConfigured();
278
245
  const r = new DataReader(ctx.request.body);
279
246
  const username = r.readString();
280
247
  const blinded = r.readBytes();
@@ -318,7 +285,6 @@ class Auth {
318
285
  * resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie */
319
286
  @post('/login/finish')
320
287
  public loginFinish(ctx: RouteContext): Response {
321
- ensureConfigured();
322
288
  const r = new DataReader(ctx.request.body);
323
289
  const cid = r.readBytes();
324
290
  const ct = r.readBytes();
@@ -330,19 +296,24 @@ class Auth {
330
296
  if (ch == null) return fail();
331
297
  if (nowSecs() >= ch.exp) return fail();
332
298
 
333
- // 2. Rebuild the v2 message from OUR stored values + the client's ct,
334
- // load the account key, verify the login signature.
299
+ // 2. Rebuild the message from OUR stored values + the client's ct (and
300
+ // the bound params + server key id), load the account key, verify.
335
301
  const acct = getAccount(ch.username);
336
302
  if (acct == null) return fail();
337
- const message = AuthService.buildLoginMessageV2(ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp, ct);
303
+ const message = AuthService.buildLoginMessage(
304
+ ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp,
305
+ ct, DEMO_MEM_KIB, DEMO_ITERS, DEMO_PAR, AuthService.serverKemKeyId(),
306
+ );
338
307
  if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
339
308
 
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.
309
+ // 3. Decapsulate (proves WE hold the KEM key), derive the session key K
310
+ // bound to the transcript, and build the confirmation tag the client
311
+ // verifies for mutual auth.
342
312
  const sharedSecret = AuthService.mlkemDecapsulate(ct);
343
313
  if (sharedSecret.length != AuthService.SHARED_SECRET_LEN) return fail();
344
314
  const transcriptHash = AuthService.sha256(message);
345
- const confirm = AuthService.serverConfirmTag(sharedSecret, transcriptHash);
315
+ const sessionKey = AuthService.deriveSessionKey(sharedSecret, transcriptHash);
316
+ const confirm = AuthService.serverConfirmTag(sessionKey, transcriptHash);
346
317
 
347
318
  // 4. Success: mint the session and return {0, sessionToken, confirm}.
348
319
  const userData = encodeSessionUser(ch.username);
@@ -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
+ * No secret wiring is needed: `AuthService` reads `AUTH_SESSION_SECRET` from the
20
+ * env store automatically (with a clearly-insecure DEV fallback), so the cookie
21
+ * minted on login and re-verified here by the `@auth` gate always agree, even
22
+ * though each request runs in its own fresh wasm instance. A real deployment just
23
+ * sets `AUTH_SESSION_SECRET` 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.52",
4
+ "version": "0.0.54",
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": {
@@ -10,6 +10,7 @@
10
10
  // and the toiljs dev-server mock).
11
11
 
12
12
  import { DataWriter, DataReader } from 'data';
13
+ import { HmacImportParams, HmacParams, ALG_SHA_256, USAGE_SIGN, USAGE_VERIFY } from 'crypto';
13
14
 
14
15
  import {
15
16
  Server,
@@ -69,34 +70,113 @@ declare function __toilVoprfEvaluate(
69
70
  outPtr: usize,
70
71
  ): i32;
71
72
 
72
- // HMAC key for signing session cookies. The SAME secret must be configured on
73
- // every edge instance (a sealed cookie minted by one is opened by another) and
74
- // must NEVER reach the client. There is no host-config secret mechanism yet, so
75
- // the tenant supplies one at startup via `AuthService.setSecret(...)` (a
76
- // build-time constant is consistent across instances). The default below is a
77
- // well-known DEV placeholder: a deployment that does not call `setSecret` gets a
78
- // loud, insecure-but-functional session so local dev works out of the box.
79
- // TODO(secret): replace with a per-deployment host-config secret.
80
- let __sessionSecret: Uint8Array = Uint8Array.wrap(
81
- String.UTF8.encode('toil-dev-insecure-session-secret-CHANGE-ME'),
82
- );
83
-
84
- // OPRF master seed (RFC 9497 DeriveKeyPair input). Per-user OPRF keys are
85
- // derived from this + the username, so it is a server secret of the same
86
- // sensitivity as a password-hash pepper: a leak enables an offline dictionary
87
- // attack (but precomputation stays impossible until it leaks). Configured at
88
- // startup via `AuthService.setOprfSeed`; the DEV default below is well-known.
89
- // 32 bytes (RFC 9497 Ns for ristretto255); `setOprfSeed` MUST also pass 32.
90
- let __oprfSeed: Uint8Array = Uint8Array.wrap(
91
- String.UTF8.encode('toil-dev-oprf-seedXXCHANGE-ME-32'),
92
- );
93
-
94
- // Server static ML-KEM-768 secret (decapsulation) key. The matching public key
95
- // is PINNED in the client; only the holder of this key can decapsulate, so a
96
- // correct shared secret authenticates the server. Configured at startup via
97
- // `AuthService.setServerKemSecretKey` (2400 bytes). Empty until set; mutual-auth
98
- // calls fail closed if unset.
99
- let __serverKemSk: Uint8Array = new Uint8Array(0);
73
+ // Secret configuration is AUTOMATIC. Every secret below resolves on first use
74
+ // from, in order: (1) an explicit `set*()` override, (2) the tenant's env store
75
+ // (`Environment.getSecure`, backed by `.env.secrets` / the dashboard) under the
76
+ // key named below, then (3) a well-known, clearly-insecure DEV fallback so local
77
+ // dev and the examples run with zero config. The resolved value is cached in the
78
+ // module global. Because every request runs in a FRESH wasm instance that reads
79
+ // the SAME env value the SAME way, there is no per-route / per-instance secret to
80
+ // keep in sync by hand: a cookie minted by one route can never fail to verify in
81
+ // another for want of a `setSecret` call. A real deployment sets the env values
82
+ // (and pins its own server KEM public key in the client); the DEV fallbacks are
83
+ // never a place to put a real secret.
84
+
85
+ /** Env-store keys the framework reads automatically (tenant-set secrets). */
86
+ const ENV_SESSION_SECRET: string = 'AUTH_SESSION_SECRET';
87
+ const ENV_OPRF_SEED: string = 'AUTH_OPRF_SEED';
88
+ const ENV_KEM_SK: string = 'AUTH_KEM_SK';
89
+
90
+ /** Well-known DEV fallbacks (insecure; overridden by env or `set*()`). */
91
+ const DEV_SESSION_SECRET: string = 'toil-dev-insecure-session-secret-CHANGE-ME';
92
+ const DEV_OPRF_SEED_SRC: string = 'toil-dev-oprf-seed-v1';
93
+ // A well-known DEV ML-KEM-768 decapsulation key (hex). Its public half (the `ek`
94
+ // at bytes [1152, 2336) of the dk) is PINNED in the client (`src/client/auth.ts`,
95
+ // `SERVER_KEM_PUBLIC_KEY`), so the PQ-auth example runs with zero config. A real
96
+ // deployment sets `AUTH_KEM_SK` and pins its own public key; never treat this as
97
+ // secret. Tree-shaken away unless a server actually calls the ML-KEM path.
98
+ const DEV_KEM_SK_HEX: string = '3156a8eb11c62bdb4af9fc57bef470f880ae340373bcc61662748a9742a639b9ad6bc55a77a82e0caa99ede237b4783ce70ab08ecc5802a9478c4ca3de67acd7a2147db43fdba408e9765443f37e9e90cc09f836d53879b890126bd6c33d55a6d97636a28ba10e18ac919aa9d37c2e4d07b6c930a5cb3238c8338fbb1abe7dac124c93462ebc5ae81cb132947993a74f9602610eab68b7fc9407b58e958aca054443246240c484c650962408168632c303cfc738d3b918ee04a37c2436b6f7300b8c6e7bd528bc5c229673c3a1bc4ae4265772f654ed8377b285626c67a4ef715a5a04a56804c3fae93ca5e3219cd68649622ee0d77bcb664a68e377260a3a38c2739b81c3c9ec510b66acde5041f3b52922a17019dc9afaec71c3e3c3102686ceb019da138b22463ad7f452640526d1d8b21c9111ca844149d1391c937b84287f1a228342c06ccb87c31cb14227e175007c5c4497c11e8647377234a84ab2640aa8ee7acb54954f99155cf7d768446b104ac149f59ca1d0029401570db9341c93db0041d52fbbd62726a75f9ab177e4ea5176e675d28a1f9852c28b38074c91cec8064b6ba116db8b59c0434fbd1b207cd921fbf29b06740b53c7304b17b253652ad469b2cb10bf7ed3bcc5b1b6168c2d30a889f67a01ae79455100ac582ba2f764a4a4b134b9115d7c548032d55d4916ce25c0ce7c42160e446298fb10f747302e781a70b2b7962b0b54f3c0e3a4677e99cc02e41e66b0861d02d072b94ce3f8a04fd20d2ec220cea3737922808f00080186421e60b7d1076e5ca40099d54da33033021349e31bb65e12aa259b37bc975582aa6441ab2fabdc9cee0aab0c11c7e3489b93bab26e13bf399ab8a37949baba3c2f8a94fd97a9a551c96d582b5c1ba97b4547701656ee02567dd6a8362c1043c5874760c7d1133292f05c9d3689beccb903d4bd65f09e3e3255d0229daf9050ebaa107e51371fc9248393239575466a9c45b4a239e1b29b07d9701cf1bb488a95a004a98fcb1f6d548cc8554a3eb25a5fc90892618e5d33b04938567e748ab9ba79b0d39d611864b2140666c1791e79c5c0943a03038f7306551db3b271b08dec32443ae14674e16d6c42956ef36499348e7424bbc4883c37675a4f8bb28cd68f30b532ba80104e7214b9a4886045a152d161821a006ae03ae3742e36f63d997c858b850119e1004f4022a04a9533749d993641763a83dce5256f3826ae9b0584c72d69c77d6784444737a0192789e0d63a2f2808ce88b07c33383e588f68b13b892ac6998c9f2db14ba3e10eee4b9717761efc298e026974231a143b89009a724a7121292bb9292662b87502beadb9cbea3cc89de1997b376575f466b6693e18eb70630ba1823cae5f03698ae662190207156ca8d1a4a3cb926d20c92b524180c0804f057491c292024641bf9b21b52214bf2a2b42d16596e22935317bc712e64f64c143b257ca6f663223a1a2b6537b55746a2a739b2adbbfa004354a1555cc8b8215aa06413b27b7fa8c860386c13876b8d55b743860a13c0005dc4ac5e003cd3431c7a29edcc73c50b991e56a12423ac1f2842ed2999b7b31b6e01aaa83c01af658bae959b2cb256f1e7bba29d765e8083182891302569b3712a856e564fdd484b0706b0c68568d5ab7edc742cf74459d64595455a60f267973aa55e43c5be61925a3822eafcca445e36dc4655636e31e6fc9bec338b253f94290008ef7f40dbddb49c15c690f6755a23a1b3c85cfd5207e71a607086a6fc6d74a05080f43276901a19cafdb8de7771d58ea07f0f1056b905127b22223d08e75173199f13ab13c5dcd3b51ac784f84e520484a262b845a897c41cf27324ab6ba545c78c9ccab361051e0bba53498af26240fa0d566d1572684f4b42e253e6d052c848650915063c35641e1121ef8d9cfd17b667b351103c56d195007c9376d0c08aa268396814490eab4c364175a94533267a1933862cc4c33bcf0a13d1fa2b9d6c5082eeca1480672f2526cbe013beff14dc908a386e0b633c8761023cbed760deac6709bc328d865ac82e12307b673d96711dbb27a4d939230d25b53d594169a318be0200fa33550e9418e2a3b30e9719edc09d5fc4306f1abfd021eab14637a8a72c5931d25dc9b56db0e6ab677522b10f25307dbb804a6774ce05b87b0976a4b227bfe6caf20a79e64004fbd27b1eea018b3ab8ffa629f2dc87f19278f95168e94e44660a3370c537795678eb2f056260609769740583b51b291862927a1938737c6a37f40b78f00671cccbcb88ac3427b37915ed58782998f84051647707d48995472baad3f64a7cca54e1c0734db08751c614a34f28b84f2c1b5a6817355ab61957c486b7acffbc092bc8a7b46387f33b53ed372f7168d31a71cd008539928b0cdf91e835aa97f6a2be6d327b87a6ae478701d75a59a25179cb14997bb2552853014724170a1c49b82c2bcebc3279024e1fa44c53c7afdc43f0bd22116490f3b74c90e7296be58b9a91168f2fa0c3d378a3bcac959f357825c9976a8c9ee944f29b45e96d7345d9b478431a20cf1c5d3a3227c717fd204619777636c0cb140db5c50d2a3302334461030bee34e4eb1a6f02b733f9ccda4290fa168bc039568373241542728d00030d1f251e83737cb215adbdc1de75978675a0cd0d75b12748abdda7a9852629c63697d145af2c69854b06e03f37c4b064e4c9a4c03f2ad4d081e70180e9547247921918118086b62b4f7727f46b24e3e79ba3f28209f32b5102035bf935856232f83642268c0292ec6bf8e9462382163d30a20b4bcb7b4439310ec9d0a148193907fc07697342967cf1a16c6b3c71558951fa915400736cf699262b54b723abb2ecc27b74b68ee494287595ef818388adb49e883c67bfa5c226c0eef037a0851a29d34675912c1ea1068310b6dfcd017c809c8fbfc2c3ae78dfef07299960eeefba182662a90fa422c1790f356a2ea909012b15623a9b9e450a282cb530589a68368b3583159d9010ac3e52cc974753c342e58279516339dfb691df94b13a223ad97eb6a09c21dafe6304a3642d6d2067b5238497661fe88ad1227ca3557be2a576b6e17c5a7f997ea07929e76407e376aba74c44cd8504804776f39bbb8327624188a63501e83b404d9438cade0b11dc3ac61856447fb072b91761c228878f01b2eb6b4b21ba664c2c75882431603b25a449ffeb8410b910558581777562aa9b2181fd9c04713ad9326462d3e842121c4997f9aa932417c67851625816de66e0d65637434629f39d157cc40cbafccc4429c35caeda482299013baf565d0f38b8f2886b9641ae6bea5b2bfccd9e6f3000d1a2734414e5b6875828f9ca9b6c3d0ddeaf704111e2b38';
99
+
100
+ // Resolved-and-cached per instance; `null` = not yet resolved and not overridden.
101
+ let __sessionSecret: Uint8Array | null = null;
102
+ let __oprfSeed: Uint8Array | null = null;
103
+ let __serverKemSk: Uint8Array | null = null;
104
+ let __serverKemPk: Uint8Array | null = null;
105
+
106
+ function __hexNibble(c: i32): i32 {
107
+ if (c >= 48 && c <= 57) return c - 48; // 0-9
108
+ if (c >= 97 && c <= 102) return c - 87; // a-f
109
+ if (c >= 65 && c <= 70) return c - 55; // A-F
110
+ return 0;
111
+ }
112
+ function __fromHex(s: string): Uint8Array {
113
+ const out = new Uint8Array(s.length >> 1);
114
+ for (let i = 0; i < out.length; i++) {
115
+ out[i] = <u8>((__hexNibble(s.charCodeAt(i * 2)) << 4) | __hexNibble(s.charCodeAt(i * 2 + 1)));
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** The session HMAC secret (UTF-8 of the env value or the DEV fallback). */
121
+ function __resolveSessionSecret(): Uint8Array {
122
+ let s = __sessionSecret;
123
+ if (s != null) return s;
124
+ const v = Environment.getSecure(ENV_SESSION_SECRET);
125
+ s = Uint8Array.wrap(String.UTF8.encode(v != null ? v : DEV_SESSION_SECRET));
126
+ __sessionSecret = s;
127
+ return s;
128
+ }
129
+ /** The OPRF master seed, hashed to 32 bytes (RFC 9497 Ns) so any env value works. */
130
+ function __resolveOprfSeed(): Uint8Array {
131
+ let s = __oprfSeed;
132
+ if (s != null) return s;
133
+ const v = Environment.getSecure(ENV_OPRF_SEED);
134
+ s = crypto.sha256Text(v != null ? v : DEV_OPRF_SEED_SRC);
135
+ __oprfSeed = s;
136
+ return s;
137
+ }
138
+ /** The server static ML-KEM-768 secret (decapsulation) key (2400 bytes). */
139
+ function __resolveServerKemSk(): Uint8Array {
140
+ let s = __serverKemSk;
141
+ if (s != null) return s;
142
+ const v = Environment.getSecure(ENV_KEM_SK);
143
+ s = __fromHex(v != null ? v : DEV_KEM_SK_HEX);
144
+ __serverKemSk = s;
145
+ return s;
146
+ }
147
+ /** The server static ML-KEM-768 PUBLIC key (the `ek` embedded in the dk at bytes
148
+ * [1152, 2336), FIPS 203 layout), used for the key id bound into the login
149
+ * transcript. The client pins the same key. */
150
+ function __resolveServerKemPk(): Uint8Array {
151
+ let s = __serverKemPk;
152
+ if (s != null) return s;
153
+ s = __resolveServerKemSk().slice(1152, 2336);
154
+ __serverKemPk = s;
155
+ return s;
156
+ }
157
+
158
+ // HMAC-SHA256(key, msg) via the ambient Web Crypto (same path SecureCookies
159
+ // uses). The session-key derivation and the mutual-auth confirmation tag are
160
+ // both keyed PRFs over the transcript; the client mirrors this with hash-wasm.
161
+ function __hmacSha256(key: Uint8Array, msg: Uint8Array): Uint8Array {
162
+ const k = crypto.subtle.importKey(
163
+ 'raw',
164
+ key,
165
+ new HmacImportParams(ALG_SHA_256),
166
+ false,
167
+ USAGE_SIGN | USAGE_VERIFY,
168
+ );
169
+ return crypto.subtle.sign(new HmacParams(), k, msg);
170
+ }
171
+
172
+ // `utf8(label) || transcriptHash` -- the HMAC message body for the derivations.
173
+ function __labelled(label: string, transcriptHash: Uint8Array): Uint8Array {
174
+ const lb = Uint8Array.wrap(String.UTF8.encode(label));
175
+ const buf = new Uint8Array(lb.length + transcriptHash.length);
176
+ buf.set(lb, 0);
177
+ buf.set(transcriptHash, lb.length);
178
+ return buf;
179
+ }
100
180
 
101
181
  // Whether the current request arrived over HTTPS. A TLS edge / proxy signals it
102
182
  // with `x-forwarded-proto: https`; absent (plain HTTP, including `toiljs dev`)
@@ -136,9 +216,10 @@ export namespace AuthService {
136
216
  export const DEFAULT_SESSION_TTL_SECS: u64 = 86400; // 24h
137
217
 
138
218
  /**
139
- * Configure the server secret used to sign session cookies. Call once at
140
- * startup from the tenant's `main.ts`. Must be identical on every edge
141
- * instance and kept out of any client bundle.
219
+ * Override the session-signing secret programmatically. OPTIONAL: by default
220
+ * AuthService reads `AUTH_SESSION_SECRET` from the env store (with a DEV
221
+ * fallback), so most apps never call this. An override takes precedence over
222
+ * the env value for the current request; keep it out of any client bundle.
142
223
  */
143
224
  export function setSecret(secret: Uint8Array): void {
144
225
  __sessionSecret = secret;
@@ -154,7 +235,7 @@ export namespace AuthService {
154
235
  const req = Server.currentRequest;
155
236
  if (req == null) return null;
156
237
 
157
- const sealed = SecureCookies.signed(__sessionSecret).open(
238
+ const sealed = SecureCookies.signed(__resolveSessionSecret()).open(
158
239
  req.cookies(),
159
240
  sessionCookieName(__reqIsSecure()),
160
241
  );
@@ -217,7 +298,7 @@ export namespace AuthService {
217
298
  .sameSite(SameSite.Lax)
218
299
  .maxAge(<i64>ttlSecs);
219
300
  cookie = secure ? cookie.asHostPrefixed() : cookie.path('/');
220
- return SecureCookies.signed(__sessionSecret).seal(cookie);
301
+ return SecureCookies.signed(__resolveSessionSecret()).seal(cookie);
221
302
  }
222
303
 
223
304
  /** A `Set-Cookie` that immediately clears the session (logout). */
@@ -273,17 +354,23 @@ export namespace AuthService {
273
354
 
274
355
  /**
275
356
  * Build the canonical login message `M` the client signs and the server
276
- * verifies, with a FIXED binary layout (no JSON). The server MUST call this
277
- * with its OWN stored values, never with fields echoed by the client. Both
278
- * ends use this exact field order via the byte-identical `DataWriter`:
357
+ * verifies. ONE fixed binary layout, no JSON and no version negotiation. The
358
+ * server MUST call this with its OWN stored values, never fields echoed by
359
+ * the client. Both ends produce byte-identical bytes via `DataWriter`:
279
360
  *
280
- * u8 version = 1
281
- * str sub (username; u32-LE len + UTF-8)
282
- * str aud (this service's audience; server-config constant)
283
- * bytes cid (challenge id; u32-LE len + raw)
284
- * bytes nonce (32 random bytes; u32-LE len + raw)
285
- * u64 iat (issued-at, seconds, LE)
286
- * u64 exp (expiry, seconds, LE)
361
+ * u8 tag = 1 (format marker, not a compat switch)
362
+ * str sub (username)
363
+ * str aud (this service's audience; server-config constant)
364
+ * bytes cid (challenge id)
365
+ * bytes nonce (32 random bytes)
366
+ * u64 iat
367
+ * u64 exp
368
+ * bytes ct (ML-KEM ciphertext; binds the key encapsulation)
369
+ * u32 memKiB (Argon2id params, bound so a MITM cannot slip a
370
+ * u32 iterations downgrade past the signature)
371
+ * u32 parallelism
372
+ * bytes serverKemKeyId (SHA-256 of the server KEM public key; binds the
373
+ * server identity the client encapsulated to)
287
374
  */
288
375
  export function buildLoginMessage(
289
376
  sub: string,
@@ -292,6 +379,11 @@ export namespace AuthService {
292
379
  nonce: Uint8Array,
293
380
  iat: u64,
294
381
  exp: u64,
382
+ ciphertext: Uint8Array,
383
+ memKiB: u32,
384
+ iterations: u32,
385
+ parallelism: u32,
386
+ serverKemKeyId: Uint8Array,
295
387
  ): Uint8Array {
296
388
  const w = new DataWriter();
297
389
  w.writeU8(1);
@@ -301,6 +393,11 @@ export namespace AuthService {
301
393
  w.writeBytes(nonce);
302
394
  w.writeU64(iat);
303
395
  w.writeU64(exp);
396
+ w.writeBytes(ciphertext);
397
+ w.writeU32(memKiB);
398
+ w.writeU32(iterations);
399
+ w.writeU32(parallelism);
400
+ w.writeBytes(serverKemKeyId);
304
401
  return w.toBytes();
305
402
  }
306
403
 
@@ -328,11 +425,6 @@ export namespace AuthService {
328
425
  return result == 1;
329
426
  }
330
427
 
331
- // ===================================================================
332
- // Augmented-PAKE layer: OPRF keyed salt + ML-KEM mutual auth.
333
- // (See server/globals/auth.ts header and the example `Auth` controller.)
334
- // ===================================================================
335
-
336
428
  /** ML-KEM-768 (FIPS 203) sizes. */
337
429
  export const KEM_CIPHERTEXT_LEN: i32 = 1088;
338
430
  export const KEM_SECRET_KEY_LEN: i32 = 2400;
@@ -344,23 +436,35 @@ export namespace AuthService {
344
436
  export const OPRF_SEED_LEN: i32 = 32;
345
437
 
346
438
  /**
347
- * Configure the OPRF master seed (32 bytes). Per-user OPRF keys are derived
348
- * from this + the username. Call once at startup from `main.ts`; identical
349
- * on every instance, kept out of any client bundle.
439
+ * Override the OPRF master seed (32 bytes) programmatically. OPTIONAL: by
440
+ * default AuthService reads `AUTH_OPRF_SEED` from the env store (hashed to 32
441
+ * bytes, with a DEV fallback). Per-user OPRF keys derive from this + the
442
+ * username; keep it out of any client bundle.
350
443
  */
351
444
  export function setOprfSeed(seed: Uint8Array): void {
352
445
  __oprfSeed = seed;
353
446
  }
354
447
 
355
448
  /**
356
- * Configure the server static ML-KEM-768 secret (decapsulation) key (2400
357
- * bytes). The matching public key is pinned in the client. Call once at
358
- * startup; identical on every instance, never in a client bundle.
449
+ * Override the server static ML-KEM-768 secret (decapsulation) key (2400
450
+ * bytes) programmatically. OPTIONAL: by default AuthService reads `AUTH_KEM_SK`
451
+ * (hex) from the env store, with a DEV fallback whose public half is pinned in
452
+ * the client. Never put this in a client bundle.
359
453
  */
360
454
  export function setServerKemSecretKey(secretKey: Uint8Array): void {
361
455
  __serverKemSk = secretKey;
362
456
  }
363
457
 
458
+ /**
459
+ * Override the server static ML-KEM-768 PUBLIC key (1184 bytes), used to
460
+ * compute {@link serverKemKeyId}. OPTIONAL: by default it is derived from the
461
+ * secret key (the `ek` embedded at bytes [1152, 2336) of the dk), so setting
462
+ * the secret key is enough. Must be the key the client pins.
463
+ */
464
+ export function setServerKemPublicKey(publicKey: Uint8Array): void {
465
+ __serverKemPk = publicKey;
466
+ }
467
+
364
468
  /**
365
469
  * OPRF server step: blind-evaluate the client's `blinded` element under the
366
470
  * per-user key derived from the master seed + `username`. Returns the 32-byte
@@ -371,9 +475,10 @@ export namespace AuthService {
371
475
  if (blinded.length != OPRF_ELEMENT_LEN) return new Uint8Array(0);
372
476
  const info = Uint8Array.wrap(String.UTF8.encode(username));
373
477
  const out = new Uint8Array(OPRF_ELEMENT_LEN);
478
+ const seed = __resolveOprfSeed();
374
479
  const rc = __toilVoprfEvaluate(
375
- __oprfSeed.dataStart,
376
- __oprfSeed.length,
480
+ seed.dataStart,
481
+ seed.length,
377
482
  info.dataStart,
378
483
  info.length,
379
484
  blinded.dataStart,
@@ -389,15 +494,16 @@ export namespace AuthService {
389
494
  * Only the genuine server can produce this, so it underpins mutual auth.
390
495
  */
391
496
  export function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array {
392
- if (__serverKemSk.length != KEM_SECRET_KEY_LEN || ciphertext.length != KEM_CIPHERTEXT_LEN) {
497
+ const sk = __resolveServerKemSk();
498
+ if (sk.length != KEM_SECRET_KEY_LEN || ciphertext.length != KEM_CIPHERTEXT_LEN) {
393
499
  return new Uint8Array(0);
394
500
  }
395
501
  const out = new Uint8Array(SHARED_SECRET_LEN);
396
502
  const rc = __toilMlkemDecapsulate(
397
503
  ciphertext.dataStart,
398
504
  ciphertext.length,
399
- __serverKemSk.dataStart,
400
- __serverKemSk.length,
505
+ sk.dataStart,
506
+ sk.length,
401
507
  out.dataStart,
402
508
  );
403
509
  return rc == 0 ? out : new Uint8Array(0);
@@ -408,53 +514,41 @@ export namespace AuthService {
408
514
  return crypto.subtle.digest('SHA-256', data);
409
515
  }
410
516
 
411
- /**
412
- * The canonical login message `M` (version 2), extending
413
- * {@link buildLoginMessage} with the ML-KEM ciphertext so the signature
414
- * binds the key-encapsulation. Byte-identical to the client's
415
- * `buildLoginMessageV2`. Layout: the v1 fields, then `bytes ct`.
416
- */
417
- export function buildLoginMessageV2(
418
- sub: string,
419
- aud: string,
420
- cid: Uint8Array,
421
- nonce: Uint8Array,
422
- iat: u64,
423
- exp: u64,
424
- ciphertext: Uint8Array,
425
- ): Uint8Array {
426
- const w = new DataWriter();
427
- w.writeU8(2);
428
- w.writeString(sub);
429
- w.writeString(aud);
430
- w.writeBytes(cid);
431
- w.writeBytes(nonce);
432
- w.writeU64(iat);
433
- w.writeU64(exp);
434
- w.writeBytes(ciphertext);
435
- return w.toBytes();
517
+ /** `SHA-256(serverKemPublicKey)` -- the key identity bound into the login
518
+ * message, so the signature commits to which server key the client
519
+ * encapsulated to. The client computes the same hash over its pinned key. */
520
+ export function serverKemKeyId(): Uint8Array {
521
+ return sha256(__resolveServerKemPk());
436
522
  }
437
523
 
438
- /** Domain separator for the server's mutual-auth confirmation tag. */
524
+ /** Domain separators for the session-key derivation and confirmation tag. */
525
+ export const SESSION_KEY_LABEL: string = 'toil-session-key-v1';
439
526
  export const SERVER_CONFIRM_LABEL: string = 'toil-server-confirm-v1';
440
527
 
441
528
  /**
442
- * The server's mutual-auth confirmation tag: `SHA-256(label || sharedSecret
443
- * || transcriptHash)`. Only a server that correctly decapsulated (i.e. holds
444
- * the KEM secret key) can produce it, so the client verifying it proves the
445
- * server's identity. `transcriptHash` is `sha256(M)`.
529
+ * Derive the authenticated session key `K` from the ML-KEM shared secret,
530
+ * bound to the transcript: `K = HMAC-SHA256(sharedSecret, SESSION_KEY_LABEL ||
531
+ * transcriptHash)`. The shared secret is already a uniform 32-byte key, so it
532
+ * keys the HMAC directly (an HKDF-Expand step). Both ends derive the same `K`
533
+ * iff the KEM exchange and transcript match.
446
534
  *
447
- * NOTE: a SHA-256 MAC keyed by the high-entropy shared secret is adequate
448
- * here and keeps the client (insecure-context) simple; a hardened build
449
- * should derive a session key with HKDF and bind it to the channel.
535
+ * NOTE: `K` is the handle for future channel binding. Binding the *session
536
+ * cookie* to the transport (so a stolen cookie is useless on another channel)
537
+ * needs the TLS exporter, which the wasm guest cannot see -- that is an
538
+ * edge/transport follow-up, not doable purely here.
539
+ */
540
+ export function deriveSessionKey(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array {
541
+ return __hmacSha256(sharedSecret, __labelled(SESSION_KEY_LABEL, transcriptHash));
542
+ }
543
+
544
+ /**
545
+ * The server's mutual-auth confirmation tag: `HMAC-SHA256(K, SERVER_CONFIRM_LABEL
546
+ * || transcriptHash)`, where `K` is {@link deriveSessionKey}. Only a server
547
+ * that decapsulated correctly (i.e. holds the KEM secret key) derives the same
548
+ * `K`, so the client verifying this tag proves the server's identity.
450
549
  */
451
- export function serverConfirmTag(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array {
452
- const label = Uint8Array.wrap(String.UTF8.encode(SERVER_CONFIRM_LABEL));
453
- const buf = new Uint8Array(label.length + sharedSecret.length + transcriptHash.length);
454
- buf.set(label, 0);
455
- buf.set(sharedSecret, label.length);
456
- buf.set(transcriptHash, label.length + sharedSecret.length);
457
- return crypto.subtle.digest('SHA-256', buf);
550
+ export function serverConfirmTag(sessionKey: Uint8Array, transcriptHash: Uint8Array): Uint8Array {
551
+ return __hmacSha256(sessionKey, __labelled(SERVER_CONFIRM_LABEL, transcriptHash));
458
552
  }
459
553
 
460
554
  /** Registration proof-of-possession context (binds a signature to "register"