toiljs 0.0.52 → 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.
@@ -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,17 +36,25 @@ 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';
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';
43
44
 
44
- // --- small helpers ----------------------------------------------------------
45
45
 
46
46
  function utf8(s: string): Uint8Array {
47
47
  return Uint8Array.wrap(String.UTF8.encode(s));
48
48
  }
49
49
 
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;
56
+ }
57
+
50
58
  function fromHex(s: string): Uint8Array {
51
59
  const out = new Uint8Array(s.length >> 1);
52
60
  for (let i = 0; i < out.length; i++) {
@@ -96,20 +104,34 @@ function deriveSalt(username: string): Uint8Array {
96
104
  return crypto.sha256Text('toil-demo-salt-v1:' + username).slice(0, 16);
97
105
  }
98
106
 
99
- // --- startup config (idempotent; re-applied per fresh instance) -------------
100
107
 
101
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`. */
102
116
  function ensureConfigured(): void {
103
117
  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));
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));
109
132
  __configured = true;
110
133
  }
111
134
 
112
- // --- KV-backed storage (DEV-ONLY host imports; see src/devserver/kv.ts) ------
113
135
 
114
136
  // @ts-ignore: decorator
115
137
  @external('env', 'kv.put')
@@ -147,7 +169,6 @@ function chalKey(cid: Uint8Array): Uint8Array {
147
169
  return utf8('chal:' + toHex(cid));
148
170
  }
149
171
 
150
- // --- records ----------------------------------------------------------------
151
172
 
152
173
  class Account {
153
174
  username: string = '';
@@ -238,7 +259,8 @@ class Auth {
238
259
  }
239
260
 
240
261
  /** POST /auth/register/finish body: str(username) bytes(pk) bytes(regProof)
241
- * resp: u8(status). Verifies proof-of-possession before storing the key. */
262
+ * resp: u8(status) -- 0 = ok, 1 = username already registered. Verifies
263
+ * proof-of-possession before storing the key. */
242
264
  @post('/register/finish')
243
265
  public registerFinish(ctx: RouteContext): Response {
244
266
  ensureConfigured();
@@ -248,7 +270,11 @@ class Auth {
248
270
  const proof = r.readBytes();
249
271
  if (!r.ok) return fail();
250
272
  if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
251
- if (getAccount(username) != null) return fail(); // already registered
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
+ }
252
278
 
253
279
  // Proof-of-possession: the client signed buildRegisterMessage with the
254
280
  // matching secret key, so we confirm it actually holds it.
@@ -330,19 +356,24 @@ class Auth {
330
356
  if (ch == null) return fail();
331
357
  if (nowSecs() >= ch.exp) return fail();
332
358
 
333
- // 2. Rebuild the v2 message from OUR stored values + the client's ct,
334
- // load the account key, verify the login signature.
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.
335
361
  const acct = getAccount(ch.username);
336
362
  if (acct == null) return fail();
337
- const message = AuthService.buildLoginMessageV2(ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp, ct);
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
+ );
338
367
  if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
339
368
 
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.
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.
342
372
  const sharedSecret = AuthService.mlkemDecapsulate(ct);
343
373
  if (sharedSecret.length != AuthService.SHARED_SECRET_LEN) return fail();
344
374
  const transcriptHash = AuthService.sha256(message);
345
- const confirm = AuthService.serverConfirmTag(sharedSecret, transcriptHash);
375
+ const sessionKey = AuthService.deriveSessionKey(sharedSecret, transcriptHash);
376
+ const confirm = AuthService.serverConfirmTag(sessionKey, transcriptHash);
346
377
 
347
378
  // 4. Success: mint the session and return {0, sessionToken, confirm}.
348
379
  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
+ * 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.52",
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": {
@@ -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,
@@ -98,6 +99,34 @@ let __oprfSeed: Uint8Array = Uint8Array.wrap(
98
99
  // calls fail closed if unset.
99
100
  let __serverKemSk: Uint8Array = new Uint8Array(0);
100
101
 
102
+ // Server static ML-KEM-768 PUBLIC (encapsulation) key, used only to compute the
103
+ // key identity bound into the login transcript (`serverKemKeyId`). The client
104
+ // pins the same key. Configured at startup via `setServerKemPublicKey`.
105
+ let __serverKemPk: Uint8Array = new Uint8Array(0);
106
+
107
+ // HMAC-SHA256(key, msg) via the ambient Web Crypto (same path SecureCookies
108
+ // uses). The session-key derivation and the mutual-auth confirmation tag are
109
+ // both keyed PRFs over the transcript; the client mirrors this with hash-wasm.
110
+ function __hmacSha256(key: Uint8Array, msg: Uint8Array): Uint8Array {
111
+ const k = crypto.subtle.importKey(
112
+ 'raw',
113
+ key,
114
+ new HmacImportParams(ALG_SHA_256),
115
+ false,
116
+ USAGE_SIGN | USAGE_VERIFY,
117
+ );
118
+ return crypto.subtle.sign(new HmacParams(), k, msg);
119
+ }
120
+
121
+ // `utf8(label) || transcriptHash` -- the HMAC message body for the derivations.
122
+ function __labelled(label: string, transcriptHash: Uint8Array): Uint8Array {
123
+ const lb = Uint8Array.wrap(String.UTF8.encode(label));
124
+ const buf = new Uint8Array(lb.length + transcriptHash.length);
125
+ buf.set(lb, 0);
126
+ buf.set(transcriptHash, lb.length);
127
+ return buf;
128
+ }
129
+
101
130
  // Whether the current request arrived over HTTPS. A TLS edge / proxy signals it
102
131
  // with `x-forwarded-proto: https`; absent (plain HTTP, including `toiljs dev`)
103
132
  // the session uses plain cookies so they actually round-trip in the browser.
@@ -273,17 +302,23 @@ export namespace AuthService {
273
302
 
274
303
  /**
275
304
  * 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`:
305
+ * verifies. ONE fixed binary layout, no JSON and no version negotiation. The
306
+ * server MUST call this with its OWN stored values, never fields echoed by
307
+ * the client. Both ends produce byte-identical bytes via `DataWriter`:
279
308
  *
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)
309
+ * u8 tag = 1 (format marker, not a compat switch)
310
+ * str sub (username)
311
+ * str aud (this service's audience; server-config constant)
312
+ * bytes cid (challenge id)
313
+ * bytes nonce (32 random bytes)
314
+ * u64 iat
315
+ * u64 exp
316
+ * bytes ct (ML-KEM ciphertext; binds the key encapsulation)
317
+ * u32 memKiB (Argon2id params, bound so a MITM cannot slip a
318
+ * u32 iterations downgrade past the signature)
319
+ * u32 parallelism
320
+ * bytes serverKemKeyId (SHA-256 of the server KEM public key; binds the
321
+ * server identity the client encapsulated to)
287
322
  */
288
323
  export function buildLoginMessage(
289
324
  sub: string,
@@ -292,6 +327,11 @@ export namespace AuthService {
292
327
  nonce: Uint8Array,
293
328
  iat: u64,
294
329
  exp: u64,
330
+ ciphertext: Uint8Array,
331
+ memKiB: u32,
332
+ iterations: u32,
333
+ parallelism: u32,
334
+ serverKemKeyId: Uint8Array,
295
335
  ): Uint8Array {
296
336
  const w = new DataWriter();
297
337
  w.writeU8(1);
@@ -301,6 +341,11 @@ export namespace AuthService {
301
341
  w.writeBytes(nonce);
302
342
  w.writeU64(iat);
303
343
  w.writeU64(exp);
344
+ w.writeBytes(ciphertext);
345
+ w.writeU32(memKiB);
346
+ w.writeU32(iterations);
347
+ w.writeU32(parallelism);
348
+ w.writeBytes(serverKemKeyId);
304
349
  return w.toBytes();
305
350
  }
306
351
 
@@ -328,11 +373,6 @@ export namespace AuthService {
328
373
  return result == 1;
329
374
  }
330
375
 
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
376
  /** ML-KEM-768 (FIPS 203) sizes. */
337
377
  export const KEM_CIPHERTEXT_LEN: i32 = 1088;
338
378
  export const KEM_SECRET_KEY_LEN: i32 = 2400;
@@ -361,6 +401,16 @@ export namespace AuthService {
361
401
  __serverKemSk = secretKey;
362
402
  }
363
403
 
404
+ /**
405
+ * Configure the server static ML-KEM-768 PUBLIC key (1184 bytes), used to
406
+ * compute {@link serverKemKeyId}. Must be the key the client pins. (It is the
407
+ * `ek` embedded in the decapsulation key, so a tenant can pass
408
+ * `secretKey.slice(1152, 2336)` rather than store it twice.)
409
+ */
410
+ export function setServerKemPublicKey(publicKey: Uint8Array): void {
411
+ __serverKemPk = publicKey;
412
+ }
413
+
364
414
  /**
365
415
  * OPRF server step: blind-evaluate the client's `blinded` element under the
366
416
  * per-user key derived from the master seed + `username`. Returns the 32-byte
@@ -408,53 +458,41 @@ export namespace AuthService {
408
458
  return crypto.subtle.digest('SHA-256', data);
409
459
  }
410
460
 
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();
461
+ /** `SHA-256(serverKemPublicKey)` -- the key identity bound into the login
462
+ * message, so the signature commits to which server key the client
463
+ * encapsulated to. The client computes the same hash over its pinned key. */
464
+ export function serverKemKeyId(): Uint8Array {
465
+ return sha256(__serverKemPk);
436
466
  }
437
467
 
438
- /** Domain separator for the server's mutual-auth confirmation tag. */
468
+ /** Domain separators for the session-key derivation and confirmation tag. */
469
+ export const SESSION_KEY_LABEL: string = 'toil-session-key-v1';
439
470
  export const SERVER_CONFIRM_LABEL: string = 'toil-server-confirm-v1';
440
471
 
441
472
  /**
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)`.
473
+ * Derive the authenticated session key `K` from the ML-KEM shared secret,
474
+ * bound to the transcript: `K = HMAC-SHA256(sharedSecret, SESSION_KEY_LABEL ||
475
+ * transcriptHash)`. The shared secret is already a uniform 32-byte key, so it
476
+ * keys the HMAC directly (an HKDF-Expand step). Both ends derive the same `K`
477
+ * iff the KEM exchange and transcript match.
446
478
  *
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.
479
+ * NOTE: `K` is the handle for future channel binding. Binding the *session
480
+ * cookie* to the transport (so a stolen cookie is useless on another channel)
481
+ * needs the TLS exporter, which the wasm guest cannot see -- that is an
482
+ * edge/transport follow-up, not doable purely here.
483
+ */
484
+ export function deriveSessionKey(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array {
485
+ return __hmacSha256(sharedSecret, __labelled(SESSION_KEY_LABEL, transcriptHash));
486
+ }
487
+
488
+ /**
489
+ * The server's mutual-auth confirmation tag: `HMAC-SHA256(K, SERVER_CONFIRM_LABEL
490
+ * || transcriptHash)`, where `K` is {@link deriveSessionKey}. Only a server
491
+ * that decapsulated correctly (i.e. holds the KEM secret key) derives the same
492
+ * `K`, so the client verifying this tag proves the server's identity.
450
493
  */
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);
494
+ export function serverConfirmTag(sessionKey: Uint8Array, transcriptHash: Uint8Array): Uint8Array {
495
+ return __hmacSha256(sessionKey, __labelled(SERVER_CONFIRM_LABEL, transcriptHash));
458
496
  }
459
497
 
460
498
  /** Registration proof-of-possession context (binds a signature to "register"