toiljs 0.0.51 → 0.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/TYPESCRIPT_LAW.md +12601 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +16 -1
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/auth.d.ts +9 -20
  7. package/build/client/auth.js +112 -95
  8. package/build/client/index.d.ts +2 -2
  9. package/build/client/index.js +1 -1
  10. package/build/compiler/.tsbuildinfo +1 -1
  11. package/build/compiler/generate.js +1 -1
  12. package/build/devserver/.tsbuildinfo +1 -1
  13. package/build/devserver/crypto.js +33 -0
  14. package/build/devserver/host.js +2 -0
  15. package/build/devserver/kv.d.ts +3 -0
  16. package/build/devserver/kv.js +53 -0
  17. package/build/devserver/module.js +2 -1
  18. package/docs/auth-todo.md +149 -0
  19. package/docs/auth.md +234 -173
  20. package/examples/basic/client/routes/pq.tsx +72 -103
  21. package/examples/basic/server/core/AppHandler.ts +24 -3
  22. package/examples/basic/server/main.ts +0 -1
  23. package/examples/basic/server/routes/Auth.ts +304 -99
  24. package/examples/basic/server/routes/Session.ts +5 -2
  25. package/package.json +2 -1
  26. package/server/globals/auth.ts +263 -10
  27. package/src/cli/diagnostics.ts +22 -0
  28. package/src/cli/doctor.ts +2 -0
  29. package/src/client/auth.ts +192 -174
  30. package/src/client/index.ts +2 -2
  31. package/src/compiler/generate.ts +1 -1
  32. package/src/devserver/crypto.ts +54 -0
  33. package/src/devserver/host.ts +6 -0
  34. package/src/devserver/kv.ts +93 -0
  35. package/src/devserver/module.ts +4 -1
  36. package/test/devserver-pqauth.test.ts +153 -0
  37. package/test/doctor.test.ts +22 -0
  38. package/test/pqauth-e2e.test.ts +207 -0
  39. package/examples/basic/server/routes/PqDemo.ts +0 -127
@@ -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,
@@ -37,6 +38,38 @@ declare function __toilMldsaVerify(
37
38
  ctxLen: i32,
38
39
  ): i32;
39
40
 
41
+ // Host import: ML-KEM-768 (FIPS 203) decapsulation. Recovers the 32-byte shared
42
+ // secret from the client's ciphertext using the server static secret key,
43
+ // written to `outPtr`. Returns 0 on success, negative on error. The secret key
44
+ // is the server's own identity key (NOT password-derived), configured at
45
+ // startup and passed per call; the host never stores it.
46
+ // @ts-ignore: decorator
47
+ @external('env', 'crypto.mlkem_decapsulate')
48
+ declare function __toilMlkemDecapsulate(
49
+ ctPtr: usize,
50
+ ctLen: i32,
51
+ skPtr: usize,
52
+ skLen: i32,
53
+ outPtr: usize,
54
+ ): i32;
55
+
56
+ // Host import: RFC 9497 OPRF (mode 0x00, ristretto255-SHA512) server evaluation.
57
+ // Derives the per-user key from (seed, info=username) and blind-evaluates the
58
+ // client's blinded element, writing the 32-byte evaluated element to `outPtr`.
59
+ // Returns 0 on success, negative on error. `seed` is the server's secret OPRF
60
+ // master seed, configured at startup and passed per call.
61
+ // @ts-ignore: decorator
62
+ @external('env', 'crypto.voprf_evaluate')
63
+ declare function __toilVoprfEvaluate(
64
+ seedPtr: usize,
65
+ seedLen: i32,
66
+ infoPtr: usize,
67
+ infoLen: i32,
68
+ blindedPtr: usize,
69
+ blindedLen: i32,
70
+ outPtr: usize,
71
+ ): i32;
72
+
40
73
  // HMAC key for signing session cookies. The SAME secret must be configured on
41
74
  // every edge instance (a sealed cookie minted by one is opened by another) and
42
75
  // must NEVER reach the client. There is no host-config secret mechanism yet, so
@@ -49,6 +82,51 @@ let __sessionSecret: Uint8Array = Uint8Array.wrap(
49
82
  String.UTF8.encode('toil-dev-insecure-session-secret-CHANGE-ME'),
50
83
  );
51
84
 
85
+ // OPRF master seed (RFC 9497 DeriveKeyPair input). Per-user OPRF keys are
86
+ // derived from this + the username, so it is a server secret of the same
87
+ // sensitivity as a password-hash pepper: a leak enables an offline dictionary
88
+ // attack (but precomputation stays impossible until it leaks). Configured at
89
+ // startup via `AuthService.setOprfSeed`; the DEV default below is well-known.
90
+ // 32 bytes (RFC 9497 Ns for ristretto255); `setOprfSeed` MUST also pass 32.
91
+ let __oprfSeed: Uint8Array = Uint8Array.wrap(
92
+ String.UTF8.encode('toil-dev-oprf-seedXXCHANGE-ME-32'),
93
+ );
94
+
95
+ // Server static ML-KEM-768 secret (decapsulation) key. The matching public key
96
+ // is PINNED in the client; only the holder of this key can decapsulate, so a
97
+ // correct shared secret authenticates the server. Configured at startup via
98
+ // `AuthService.setServerKemSecretKey` (2400 bytes). Empty until set; mutual-auth
99
+ // calls fail closed if unset.
100
+ let __serverKemSk: Uint8Array = new Uint8Array(0);
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
+
52
130
  // Whether the current request arrived over HTTPS. A TLS edge / proxy signals it
53
131
  // with `x-forwarded-proto: https`; absent (plain HTTP, including `toiljs dev`)
54
132
  // the session uses plain cookies so they actually round-trip in the browser.
@@ -224,17 +302,23 @@ export namespace AuthService {
224
302
 
225
303
  /**
226
304
  * Build the canonical login message `M` the client signs and the server
227
- * verifies, with a FIXED binary layout (no JSON). The server MUST call this
228
- * with its OWN stored values, never with fields echoed by the client. Both
229
- * 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`:
230
308
  *
231
- * u8 version = 1
232
- * str sub (username; u32-LE len + UTF-8)
233
- * str aud (this service's audience; server-config constant)
234
- * bytes cid (challenge id; u32-LE len + raw)
235
- * bytes nonce (32 random bytes; u32-LE len + raw)
236
- * u64 iat (issued-at, seconds, LE)
237
- * 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)
238
322
  */
239
323
  export function buildLoginMessage(
240
324
  sub: string,
@@ -243,6 +327,11 @@ export namespace AuthService {
243
327
  nonce: Uint8Array,
244
328
  iat: u64,
245
329
  exp: u64,
330
+ ciphertext: Uint8Array,
331
+ memKiB: u32,
332
+ iterations: u32,
333
+ parallelism: u32,
334
+ serverKemKeyId: Uint8Array,
246
335
  ): Uint8Array {
247
336
  const w = new DataWriter();
248
337
  w.writeU8(1);
@@ -252,6 +341,11 @@ export namespace AuthService {
252
341
  w.writeBytes(nonce);
253
342
  w.writeU64(iat);
254
343
  w.writeU64(exp);
344
+ w.writeBytes(ciphertext);
345
+ w.writeU32(memKiB);
346
+ w.writeU32(iterations);
347
+ w.writeU32(parallelism);
348
+ w.writeBytes(serverKemKeyId);
255
349
  return w.toBytes();
256
350
  }
257
351
 
@@ -278,4 +372,163 @@ export namespace AuthService {
278
372
  );
279
373
  return result == 1;
280
374
  }
375
+
376
+ /** ML-KEM-768 (FIPS 203) sizes. */
377
+ export const KEM_CIPHERTEXT_LEN: i32 = 1088;
378
+ export const KEM_SECRET_KEY_LEN: i32 = 2400;
379
+ export const KEM_PUBLIC_KEY_LEN: i32 = 1184;
380
+ export const SHARED_SECRET_LEN: i32 = 32;
381
+ /** Serialized ristretto255 OPRF element (blinded / evaluated). */
382
+ export const OPRF_ELEMENT_LEN: i32 = 32;
383
+ /** RFC 9497 DeriveKeyPair seed length (ristretto255 `Ns`). */
384
+ export const OPRF_SEED_LEN: i32 = 32;
385
+
386
+ /**
387
+ * Configure the OPRF master seed (32 bytes). Per-user OPRF keys are derived
388
+ * from this + the username. Call once at startup from `main.ts`; identical
389
+ * on every instance, kept out of any client bundle.
390
+ */
391
+ export function setOprfSeed(seed: Uint8Array): void {
392
+ __oprfSeed = seed;
393
+ }
394
+
395
+ /**
396
+ * Configure the server static ML-KEM-768 secret (decapsulation) key (2400
397
+ * bytes). The matching public key is pinned in the client. Call once at
398
+ * startup; identical on every instance, never in a client bundle.
399
+ */
400
+ export function setServerKemSecretKey(secretKey: Uint8Array): void {
401
+ __serverKemSk = secretKey;
402
+ }
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
+
414
+ /**
415
+ * OPRF server step: blind-evaluate the client's `blinded` element under the
416
+ * per-user key derived from the master seed + `username`. Returns the 32-byte
417
+ * evaluated element, or an empty array on any failure. The client unblinds +
418
+ * finalizes locally and feeds the result into Argon2id (the keyed salt).
419
+ */
420
+ export function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array {
421
+ if (blinded.length != OPRF_ELEMENT_LEN) return new Uint8Array(0);
422
+ const info = Uint8Array.wrap(String.UTF8.encode(username));
423
+ const out = new Uint8Array(OPRF_ELEMENT_LEN);
424
+ const rc = __toilVoprfEvaluate(
425
+ __oprfSeed.dataStart,
426
+ __oprfSeed.length,
427
+ info.dataStart,
428
+ info.length,
429
+ blinded.dataStart,
430
+ blinded.length,
431
+ out.dataStart,
432
+ );
433
+ return rc == 0 ? out : new Uint8Array(0);
434
+ }
435
+
436
+ /**
437
+ * Decapsulate the client's ML-KEM ciphertext with the server static secret
438
+ * key, returning the 32-byte shared secret (empty on failure / unset key).
439
+ * Only the genuine server can produce this, so it underpins mutual auth.
440
+ */
441
+ export function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array {
442
+ if (__serverKemSk.length != KEM_SECRET_KEY_LEN || ciphertext.length != KEM_CIPHERTEXT_LEN) {
443
+ return new Uint8Array(0);
444
+ }
445
+ const out = new Uint8Array(SHARED_SECRET_LEN);
446
+ const rc = __toilMlkemDecapsulate(
447
+ ciphertext.dataStart,
448
+ ciphertext.length,
449
+ __serverKemSk.dataStart,
450
+ __serverKemSk.length,
451
+ out.dataStart,
452
+ );
453
+ return rc == 0 ? out : new Uint8Array(0);
454
+ }
455
+
456
+ /** SHA-256 over `data` (ambient Web Crypto), for transcript/confirm hashing. */
457
+ export function sha256(data: Uint8Array): Uint8Array {
458
+ return crypto.subtle.digest('SHA-256', data);
459
+ }
460
+
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);
466
+ }
467
+
468
+ /** Domain separators for the session-key derivation and confirmation tag. */
469
+ export const SESSION_KEY_LABEL: string = 'toil-session-key-v1';
470
+ export const SERVER_CONFIRM_LABEL: string = 'toil-server-confirm-v1';
471
+
472
+ /**
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.
478
+ *
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.
493
+ */
494
+ export function serverConfirmTag(sessionKey: Uint8Array, transcriptHash: Uint8Array): Uint8Array {
495
+ return __hmacSha256(sessionKey, __labelled(SERVER_CONFIRM_LABEL, transcriptHash));
496
+ }
497
+
498
+ /** Registration proof-of-possession context (binds a signature to "register"
499
+ * so it can never validate as a login). Byte-identical to the client. */
500
+ export const REGISTER_CONTEXT: string = 'qauth:register:v1';
501
+
502
+ /**
503
+ * The registration PoP message: `u8(1) str(username) bytes(publicKey)`,
504
+ * signed by the client under {@link REGISTER_CONTEXT}. Verifying it proves
505
+ * the registrant holds the secret key for the public key it is registering.
506
+ */
507
+ export function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array {
508
+ const w = new DataWriter();
509
+ w.writeU8(1);
510
+ w.writeString(username);
511
+ w.writeBytes(publicKey);
512
+ return w.toBytes();
513
+ }
514
+
515
+ /** Verify a registration proof-of-possession over `message` against the
516
+ * submitted `publicKey`, under {@link REGISTER_CONTEXT}. */
517
+ export function verifyRegister(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool {
518
+ if (publicKey.length != PUBLIC_KEY_LEN || signature.length != SIGNATURE_LEN) {
519
+ return false;
520
+ }
521
+ const ctx = Uint8Array.wrap(String.UTF8.encode(REGISTER_CONTEXT));
522
+ const result = __toilMldsaVerify(
523
+ publicKey.dataStart,
524
+ publicKey.length,
525
+ message.dataStart,
526
+ message.length,
527
+ signature.dataStart,
528
+ signature.length,
529
+ ctx.dataStart,
530
+ ctx.length,
531
+ );
532
+ return result == 1;
533
+ }
281
534
  }
@@ -96,6 +96,28 @@ export function checkPackageManager(lockfiles: readonly string[]): Check {
96
96
  return { id: 'pm', label: 'Package manager', status: 'pass', detail: lockfiles.join(', ') };
97
97
  }
98
98
 
99
+ /**
100
+ * Flags `npx toiljs ...` inside package.json scripts. Under `npm run`, `node_modules/.bin` is
101
+ * already on PATH, so the `npx` is redundant; worse, the extra `npx` process puts the console in
102
+ * raw / VT-input mode and does not restore it when the run is Ctrl+C'd, leaving the terminal
103
+ * echoing arrow keys as `^[[A` and mis-reading typed input (Windows cmd especially). The scaffold
104
+ * uses a bare `toiljs <cmd>`; this catches projects whose scripts wrap it in `npx`.
105
+ */
106
+ export function checkDevScripts(scripts: Record<string, string>): Check {
107
+ const NPX_TOILJS = /(?:^|[\s&|;(])npx\s+toiljs\b/;
108
+ const offenders = Object.keys(scripts).filter((name) => NPX_TOILJS.test(scripts[name] ?? ''));
109
+ if (offenders.length === 0) {
110
+ return { id: 'scripts-npx', label: 'Scripts', status: 'pass' };
111
+ }
112
+ return {
113
+ id: 'scripts-npx',
114
+ label: 'Scripts',
115
+ status: 'warn',
116
+ detail: `${offenders.join(', ')} run via "npx toiljs"`,
117
+ fix: 'Drop npx: use "toiljs <cmd>" (npm run already puts node_modules/.bin on PATH). The npx layer can leave the terminal in raw mode after Ctrl+C, which garbles the shell.',
118
+ };
119
+ }
120
+
99
121
  export function checkToiljsInstalled(version: string | null): Check {
100
122
  return version
101
123
  ? { id: 'toiljs', label: 'toiljs', status: 'pass', detail: version }
package/src/cli/doctor.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type Check,
17
17
  checkBasePath,
18
18
  checkConfigLoads,
19
+ checkDevScripts,
19
20
  checkDir,
20
21
  checkDuplicatePatterns,
21
22
  type CheckGroup,
@@ -670,6 +671,7 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
670
671
  checkRoutesPresent(routes.length),
671
672
  checkDuplicatePatterns(mainPatterns),
672
673
  checkRelativeAssets(assetIssues),
674
+ checkDevScripts(projectPkg ? stringRecord(projectPkg.scripts) : {}),
673
675
  ],
674
676
  },
675
677
  {