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.
@@ -37,6 +37,38 @@ declare function __toilMldsaVerify(
37
37
  ctxLen: i32,
38
38
  ): i32;
39
39
 
40
+ // Host import: ML-KEM-768 (FIPS 203) decapsulation. Recovers the 32-byte shared
41
+ // secret from the client's ciphertext using the server static secret key,
42
+ // written to `outPtr`. Returns 0 on success, negative on error. The secret key
43
+ // is the server's own identity key (NOT password-derived), configured at
44
+ // startup and passed per call; the host never stores it.
45
+ // @ts-ignore: decorator
46
+ @external('env', 'crypto.mlkem_decapsulate')
47
+ declare function __toilMlkemDecapsulate(
48
+ ctPtr: usize,
49
+ ctLen: i32,
50
+ skPtr: usize,
51
+ skLen: i32,
52
+ outPtr: usize,
53
+ ): i32;
54
+
55
+ // Host import: RFC 9497 OPRF (mode 0x00, ristretto255-SHA512) server evaluation.
56
+ // Derives the per-user key from (seed, info=username) and blind-evaluates the
57
+ // client's blinded element, writing the 32-byte evaluated element to `outPtr`.
58
+ // Returns 0 on success, negative on error. `seed` is the server's secret OPRF
59
+ // master seed, configured at startup and passed per call.
60
+ // @ts-ignore: decorator
61
+ @external('env', 'crypto.voprf_evaluate')
62
+ declare function __toilVoprfEvaluate(
63
+ seedPtr: usize,
64
+ seedLen: i32,
65
+ infoPtr: usize,
66
+ infoLen: i32,
67
+ blindedPtr: usize,
68
+ blindedLen: i32,
69
+ outPtr: usize,
70
+ ): i32;
71
+
40
72
  // HMAC key for signing session cookies. The SAME secret must be configured on
41
73
  // every edge instance (a sealed cookie minted by one is opened by another) and
42
74
  // must NEVER reach the client. There is no host-config secret mechanism yet, so
@@ -49,6 +81,23 @@ let __sessionSecret: Uint8Array = Uint8Array.wrap(
49
81
  String.UTF8.encode('toil-dev-insecure-session-secret-CHANGE-ME'),
50
82
  );
51
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);
100
+
52
101
  // Whether the current request arrived over HTTPS. A TLS edge / proxy signals it
53
102
  // with `x-forwarded-proto: https`; absent (plain HTTP, including `toiljs dev`)
54
103
  // the session uses plain cookies so they actually round-trip in the browser.
@@ -278,4 +327,170 @@ export namespace AuthService {
278
327
  );
279
328
  return result == 1;
280
329
  }
330
+
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
+ /** ML-KEM-768 (FIPS 203) sizes. */
337
+ export const KEM_CIPHERTEXT_LEN: i32 = 1088;
338
+ export const KEM_SECRET_KEY_LEN: i32 = 2400;
339
+ export const KEM_PUBLIC_KEY_LEN: i32 = 1184;
340
+ export const SHARED_SECRET_LEN: i32 = 32;
341
+ /** Serialized ristretto255 OPRF element (blinded / evaluated). */
342
+ export const OPRF_ELEMENT_LEN: i32 = 32;
343
+ /** RFC 9497 DeriveKeyPair seed length (ristretto255 `Ns`). */
344
+ export const OPRF_SEED_LEN: i32 = 32;
345
+
346
+ /**
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.
350
+ */
351
+ export function setOprfSeed(seed: Uint8Array): void {
352
+ __oprfSeed = seed;
353
+ }
354
+
355
+ /**
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.
359
+ */
360
+ export function setServerKemSecretKey(secretKey: Uint8Array): void {
361
+ __serverKemSk = secretKey;
362
+ }
363
+
364
+ /**
365
+ * OPRF server step: blind-evaluate the client's `blinded` element under the
366
+ * per-user key derived from the master seed + `username`. Returns the 32-byte
367
+ * evaluated element, or an empty array on any failure. The client unblinds +
368
+ * finalizes locally and feeds the result into Argon2id (the keyed salt).
369
+ */
370
+ export function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array {
371
+ if (blinded.length != OPRF_ELEMENT_LEN) return new Uint8Array(0);
372
+ const info = Uint8Array.wrap(String.UTF8.encode(username));
373
+ const out = new Uint8Array(OPRF_ELEMENT_LEN);
374
+ const rc = __toilVoprfEvaluate(
375
+ __oprfSeed.dataStart,
376
+ __oprfSeed.length,
377
+ info.dataStart,
378
+ info.length,
379
+ blinded.dataStart,
380
+ blinded.length,
381
+ out.dataStart,
382
+ );
383
+ return rc == 0 ? out : new Uint8Array(0);
384
+ }
385
+
386
+ /**
387
+ * Decapsulate the client's ML-KEM ciphertext with the server static secret
388
+ * key, returning the 32-byte shared secret (empty on failure / unset key).
389
+ * Only the genuine server can produce this, so it underpins mutual auth.
390
+ */
391
+ export function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array {
392
+ if (__serverKemSk.length != KEM_SECRET_KEY_LEN || ciphertext.length != KEM_CIPHERTEXT_LEN) {
393
+ return new Uint8Array(0);
394
+ }
395
+ const out = new Uint8Array(SHARED_SECRET_LEN);
396
+ const rc = __toilMlkemDecapsulate(
397
+ ciphertext.dataStart,
398
+ ciphertext.length,
399
+ __serverKemSk.dataStart,
400
+ __serverKemSk.length,
401
+ out.dataStart,
402
+ );
403
+ return rc == 0 ? out : new Uint8Array(0);
404
+ }
405
+
406
+ /** SHA-256 over `data` (ambient Web Crypto), for transcript/confirm hashing. */
407
+ export function sha256(data: Uint8Array): Uint8Array {
408
+ return crypto.subtle.digest('SHA-256', data);
409
+ }
410
+
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();
436
+ }
437
+
438
+ /** Domain separator for the server's mutual-auth confirmation tag. */
439
+ export const SERVER_CONFIRM_LABEL: string = 'toil-server-confirm-v1';
440
+
441
+ /**
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)`.
446
+ *
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.
450
+ */
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);
458
+ }
459
+
460
+ /** Registration proof-of-possession context (binds a signature to "register"
461
+ * so it can never validate as a login). Byte-identical to the client. */
462
+ export const REGISTER_CONTEXT: string = 'qauth:register:v1';
463
+
464
+ /**
465
+ * The registration PoP message: `u8(1) str(username) bytes(publicKey)`,
466
+ * signed by the client under {@link REGISTER_CONTEXT}. Verifying it proves
467
+ * the registrant holds the secret key for the public key it is registering.
468
+ */
469
+ export function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array {
470
+ const w = new DataWriter();
471
+ w.writeU8(1);
472
+ w.writeString(username);
473
+ w.writeBytes(publicKey);
474
+ return w.toBytes();
475
+ }
476
+
477
+ /** Verify a registration proof-of-possession over `message` against the
478
+ * submitted `publicKey`, under {@link REGISTER_CONTEXT}. */
479
+ export function verifyRegister(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool {
480
+ if (publicKey.length != PUBLIC_KEY_LEN || signature.length != SIGNATURE_LEN) {
481
+ return false;
482
+ }
483
+ const ctx = Uint8Array.wrap(String.UTF8.encode(REGISTER_CONTEXT));
484
+ const result = __toilMldsaVerify(
485
+ publicKey.dataStart,
486
+ publicKey.length,
487
+ message.dataStart,
488
+ message.length,
489
+ signature.dataStart,
490
+ signature.length,
491
+ ctx.dataStart,
492
+ ctx.length,
493
+ );
494
+ return result == 1;
495
+ }
281
496
  }
@@ -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
  {
@@ -11,13 +11,39 @@
11
11
  * JSON, byte-identical to the server's `AuthService.buildLoginMessage`.
12
12
  */
13
13
 
14
- import { argon2id, sha256 } from 'hash-wasm';
14
+ import { argon2id, sha256, createSHA256 } from 'hash-wasm';
15
15
  import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
16
+ import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
17
+ import { ristretto255_oprf } from '@noble/curves/ed25519.js';
16
18
 
17
19
  import { DataReader, DataWriter } from 'toiljs/io';
18
20
 
19
21
  /** FIPS 204 signing context (domain separator). Byte-identical to the server. */
20
22
  export const LOGIN_CONTEXT = 'qauth:login:v1';
23
+ /** Registration proof-of-possession context (binds a sig to "register"). */
24
+ export const REGISTER_CONTEXT = 'qauth:register:v1';
25
+ /** Domain separator for the server's mutual-auth confirmation tag. */
26
+ export const SERVER_CONFIRM_LABEL = 'toil-server-confirm-v1';
27
+
28
+ /** ML-KEM-768 sizes (FIPS 203). */
29
+ export const KEM_PUBLIC_KEY_LEN = 1184;
30
+ export const KEM_CIPHERTEXT_LEN = 1088;
31
+ export const SHARED_SECRET_LEN = 32;
32
+
33
+ /** Lowercase-hex -> bytes. */
34
+ function fromHex(hex: string): Uint8Array {
35
+ const out = new Uint8Array(hex.length / 2);
36
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * The server's PINNED static ML-KEM-768 public key. The client encapsulates to
42
+ * it; only the genuine server (holder of the matching secret key) can
43
+ * decapsulate, so a valid confirmation tag authenticates the server. This is
44
+ * the demo dev key; a real deployment pins its own (and rotates it).
45
+ */
46
+ export const SERVER_KEM_PUBLIC_KEY = fromHex('29d765e8083182891302569b3712a856e564fdd484b0706b0c68568d5ab7edc742cf74459d64595455a60f267973aa55e43c5be61925a3822eafcca445e36dc4655636e31e6fc9bec338b253f94290008ef7f40dbddb49c15c690f6755a23a1b3c85cfd5207e71a607086a6fc6d74a05080f43276901a19cafdb8de7771d58ea07f0f1056b905127b22223d08e75173199f13ab13c5dcd3b51ac784f84e520484a262b845a897c41cf27324ab6ba545c78c9ccab361051e0bba53498af26240fa0d566d1572684f4b42e253e6d052c848650915063c35641e1121ef8d9cfd17b667b351103c56d195007c9376d0c08aa268396814490eab4c364175a94533267a1933862cc4c33bcf0a13d1fa2b9d6c5082eeca1480672f2526cbe013beff14dc908a386e0b633c8761023cbed760deac6709bc328d865ac82e12307b673d96711dbb27a4d939230d25b53d594169a318be0200fa33550e9418e2a3b30e9719edc09d5fc4306f1abfd021eab14637a8a72c5931d25dc9b56db0e6ab677522b10f25307dbb804a6774ce05b87b0976a4b227bfe6caf20a79e64004fbd27b1eea018b3ab8ffa629f2dc87f19278f95168e94e44660a3370c537795678eb2f056260609769740583b51b291862927a1938737c6a37f40b78f00671cccbcb88ac3427b37915ed58782998f84051647707d48995472baad3f64a7cca54e1c0734db08751c614a34f28b84f2c1b5a6817355ab61957c486b7acffbc092bc8a7b46387f33b53ed372f7168d31a71cd008539928b0cdf91e835aa97f6a2be6d327b87a6ae478701d75a59a25179cb14997bb2552853014724170a1c49b82c2bcebc3279024e1fa44c53c7afdc43f0bd22116490f3b74c90e7296be58b9a91168f2fa0c3d378a3bcac959f357825c9976a8c9ee944f29b45e96d7345d9b478431a20cf1c5d3a3227c717fd204619777636c0cb140db5c50d2a3302334461030bee34e4eb1a6f02b733f9ccda4290fa168bc039568373241542728d00030d1f251e83737cb215adbdc1de75978675a0cd0d75b12748abdda7a9852629c63697d145af2c69854b06e03f37c4b064e4c9a4c03f2ad4d081e70180e9547247921918118086b62b4f7727f46b24e3e79ba3f28209f32b5102035bf935856232f83642268c0292ec6bf8e9462382163d30a20b4bcb7b4439310ec9d0a148193907fc07697342967cf1a16c6b3c71558951fa915400736cf699262b54b723abb2ecc27b74b68ee494287595ef818388adb49e883c67bfa5c226c0eef037a0851a29d34675912c1ea1068310b6dfcd017c809c8fbfc2c3ae78dfef07299960eeefba182662a90fa422c1790f356a2ea909012b15623a9b9e450a282cb530589a68368b3583159d9010ac3e52cc974753c342e58279516339dfb691df94b13a223ad97eb6a09c21dafe6304a3642d6d2067b5238497661fe88ad1227ca3557be2a576b6e17c5a7f997ea07929e76407e376aba74c44cd8504804776f39bbb8327624188a63501e83b404d9438cade0b11dc3ac61856447fb072b91761c228878f01b2eb6b4b21ba664c2c75882431603b25a449ffeb8410b910558581777562aa9b2181fd9c04713ad9326462d3e842121c4997f9aa932417c67851625816de66e0d65637434629f39');
21
47
 
22
48
  export const PUBLIC_KEY_LEN = 1312;
23
49
  export const SECRET_KEY_LEN = 2560;
@@ -38,16 +64,6 @@ export interface KdfParams {
38
64
  readonly salt: Uint8Array;
39
65
  }
40
66
 
41
- /** A server login challenge. */
42
- export interface Challenge {
43
- readonly cid: Uint8Array;
44
- readonly aud: string;
45
- readonly kdf: KdfParams;
46
- readonly nonce: Uint8Array;
47
- readonly iat: bigint;
48
- readonly exp: bigint;
49
- }
50
-
51
67
  /** Overwrite a secret buffer with random bytes, then zero. Best-effort: JS GC
52
68
  * cannot scrub copies, so we never store or close over secrets beyond one call. */
53
69
  function wipe(buf: Uint8Array): void {
@@ -55,10 +71,13 @@ function wipe(buf: Uint8Array): void {
55
71
  buf.fill(0);
56
72
  }
57
73
 
58
- /** Argon2id(NFKC(password), salt; m,t,p, len=32) -> 32-byte ML-DSA seed. */
59
- async function deriveSeed(password: string, kdf: KdfParams): Promise<Uint8Array> {
74
+ /** Argon2id(oprfOutput, salt; m,t,p, len=32) -> 32-byte ML-DSA seed. The KDF
75
+ * input is the OPRF output (the keyed salt), NOT the raw password: the password
76
+ * is first run through the server-keyed OPRF, so a server breach yields no
77
+ * precomputable salt. */
78
+ async function deriveSeed(oprfOutput: Uint8Array, kdf: KdfParams): Promise<Uint8Array> {
60
79
  return argon2id({
61
- password: new TextEncoder().encode(password.normalize('NFKC')),
80
+ password: oprfOutput,
62
81
  salt: kdf.salt,
63
82
  iterations: kdf.iterations,
64
83
  parallelism: kdf.parallelism,
@@ -68,6 +87,37 @@ async function deriveSeed(password: string, kdf: KdfParams): Promise<Uint8Array>
68
87
  });
69
88
  }
70
89
 
90
+ const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s);
91
+
92
+ /** SHA-256 over `data` -> 32 raw bytes. Uses hash-wasm (pure WASM), so it works
93
+ * in an insecure context (plain HTTP) where `crypto.subtle` is undefined. */
94
+ async function sha256Bytes(data: Uint8Array): Promise<Uint8Array> {
95
+ const h = await createSHA256();
96
+ h.init();
97
+ h.update(data);
98
+ return h.digest('binary') as unknown as Uint8Array;
99
+ }
100
+
101
+ function concatBytes(...parts: Uint8Array[]): Uint8Array {
102
+ let n = 0;
103
+ for (const p of parts) n += p.length;
104
+ const out = new Uint8Array(n);
105
+ let off = 0;
106
+ for (const p of parts) {
107
+ out.set(p, off);
108
+ off += p.length;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Length-checked constant-time-ish equality (no early-exit on content). */
114
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
115
+ if (a.length !== b.length) return false;
116
+ let diff = 0;
117
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
118
+ return diff === 0;
119
+ }
120
+
71
121
  /** The canonical login message `M`, fixed binary layout (see the server's
72
122
  * `AuthService.buildLoginMessage`). Both ends MUST produce identical bytes. */
73
123
  export function buildLoginMessage(
@@ -89,6 +139,37 @@ export function buildLoginMessage(
89
139
  .toBytes();
90
140
  }
91
141
 
142
+ /** Login message `M` v2: the v1 fields plus the ML-KEM ciphertext, so the
143
+ * ML-DSA signature binds the key encapsulation. Byte-identical to the server's
144
+ * `AuthService.buildLoginMessageV2`. */
145
+ export function buildLoginMessageV2(
146
+ sub: string,
147
+ aud: string,
148
+ cid: Uint8Array,
149
+ nonce: Uint8Array,
150
+ iat: bigint,
151
+ exp: bigint,
152
+ ciphertext: Uint8Array,
153
+ ): Uint8Array {
154
+ return new DataWriter()
155
+ .writeU8(2)
156
+ .writeString(sub)
157
+ .writeString(aud)
158
+ .writeBytes(cid)
159
+ .writeBytes(nonce)
160
+ .writeU64(iat)
161
+ .writeU64(exp)
162
+ .writeBytes(ciphertext)
163
+ .toBytes();
164
+ }
165
+
166
+ /** Registration proof-of-possession message: `u8(1) str(username) bytes(pk)`,
167
+ * signed under {@link REGISTER_CONTEXT}. Byte-identical to the server's
168
+ * `buildRegisterMessage`. */
169
+ export function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array {
170
+ return new DataWriter().writeU8(1).writeString(username).writeBytes(publicKey).toBytes();
171
+ }
172
+
92
173
  // ---- wire codecs (the example `Auth` @rest controller mirrors these) -------
93
174
 
94
175
  function decodeKdf(r: DataReader): KdfParams {
@@ -100,15 +181,6 @@ function decodeKdf(r: DataReader): KdfParams {
100
181
  };
101
182
  }
102
183
 
103
- function decodeChallenge(r: DataReader): Challenge {
104
- const cid = r.readBytes();
105
- const aud = r.readString();
106
- const kdf = decodeKdf(r);
107
- const nonce = r.readBytes();
108
- const iat = r.readU64();
109
- const exp = r.readU64();
110
- return { cid, aud, kdf, nonce, iat, exp };
111
- }
112
184
 
113
185
  async function postBinary(baseUrl: string, path: string, body: Uint8Array): Promise<DataReader> {
114
186
  const res = await fetch(baseUrl + path, {
@@ -127,65 +199,107 @@ export interface AuthOptions {
127
199
  }
128
200
 
129
201
  /**
130
- * Register a new account: the server issues a salt + KDF params, the client
131
- * derives the keypair and submits ONLY the public key. Throws on failure.
202
+ * Register a new account (OPAQUE-style). The password never leaves the browser:
203
+ * it is blinded and run through the server-keyed OPRF, the OPRF output is
204
+ * stretched with Argon2id into an ML-DSA-44 keypair, and ONLY the public key
205
+ * (plus a proof-of-possession signature) is submitted. Throws on failure.
132
206
  */
133
207
  export async function register(username: string, password: string, opts: AuthOptions = {}): Promise<void> {
134
208
  const baseUrl = opts.baseUrl ?? '/auth';
209
+ const oprf = ristretto255_oprf.oprf;
210
+ const pw = utf8(password.normalize('NFKC'));
135
211
 
136
- // 1. Ask the server for a salt + params (it also confirms the name is free).
137
- const start = await postBinary(baseUrl, '/register/start', new DataWriter().writeString(username).toBytes());
212
+ // 1. Blind the password and start registration: the server confirms the name
213
+ // is free, issues salt + KDF params, and OPRF-evaluates the blinded input.
214
+ const { blind, blinded } = oprf.blind(pw);
215
+ const start = await postBinary(
216
+ baseUrl,
217
+ '/register/start',
218
+ new DataWriter().writeString(username).writeBytes(blinded).toBytes(),
219
+ );
138
220
  const status = start.readU8();
139
221
  if (status !== 0) throw new Error('auth: registration unavailable');
140
222
  const kdf = decodeKdf(start);
223
+ const evaluated = start.readBytes();
141
224
 
142
- // 2. Derive, keep only the public key, wipe the secret + seed immediately.
143
- const seed = await deriveSeed(password, kdf);
225
+ // 2. Finalize the OPRF -> keyed salt -> seed -> keypair. Keep only the public
226
+ // key + a PoP signature; wipe the secret key and seed immediately.
227
+ const oprfOutput = oprf.finalize(pw, blind, evaluated);
228
+ const seed = await deriveSeed(oprfOutput, kdf);
144
229
  let publicKey: Uint8Array;
230
+ let regProof: Uint8Array;
145
231
  try {
146
232
  const kp = ml_dsa44.keygen(seed);
147
233
  publicKey = kp.publicKey;
148
- wipe(kp.secretKey);
234
+ try {
235
+ regProof = ml_dsa44.sign(buildRegisterMessage(username, publicKey), kp.secretKey, {
236
+ context: utf8(REGISTER_CONTEXT),
237
+ });
238
+ } finally {
239
+ wipe(kp.secretKey);
240
+ }
149
241
  } finally {
150
242
  wipe(seed);
151
243
  }
152
244
  if (publicKey.length !== PUBLIC_KEY_LEN) throw new Error('auth: bad public key length');
153
245
 
154
- // 3. Submit the public key.
246
+ // 3. Submit the public key + proof-of-possession.
155
247
  const finish = await postBinary(
156
248
  baseUrl,
157
249
  '/register/finish',
158
- new DataWriter().writeString(username).writeBytes(publicKey).toBytes(),
250
+ new DataWriter().writeString(username).writeBytes(publicKey).writeBytes(regProof).toBytes(),
159
251
  );
160
252
  if (finish.readU8() !== 0) throw new Error('auth: registration rejected');
161
253
  }
162
254
 
163
255
  /**
164
- * Log in: fetch a challenge, re-derive the keypair, sign the rebuilt message
165
- * under the login context, and submit only `{cid, signature}`. The secret key
166
- * and seed are wiped the instant the single sign completes. Returns the opaque
167
- * session token the server mints (and any session cookie it sets). Throws on
168
- * failure with one generic message.
256
+ * Log in (OPAQUE-style, with ML-KEM mutual auth). Steps:
257
+ * 1. Blind the password; `login/start` returns the challenge + the OPRF
258
+ * evaluation (a fully-formed response even for unknown users -> no oracle).
259
+ * 2. Finalize the OPRF -> keyed salt -> seed -> ML-DSA keypair.
260
+ * 3. Encapsulate a shared secret to the PINNED server ML-KEM public key, build
261
+ * the v2 message (which binds the ciphertext), and sign it once.
262
+ * 4. Submit `{cid, ct, signature}`; the server consumes the challenge, verifies
263
+ * the signature, decapsulates, and returns a confirmation tag.
264
+ * 5. Verify that tag against our own shared secret -> the server proved it
265
+ * holds the KEM secret key (mutual authentication).
266
+ * The secret key, seed, and shared secret are wiped as soon as they are used.
267
+ * Returns the opaque session token. Throws (one generic message) on any failure.
169
268
  */
170
269
  export async function login(username: string, password: string, opts: AuthOptions = {}): Promise<Uint8Array> {
171
270
  const baseUrl = opts.baseUrl ?? '/auth';
271
+ const oprf = ristretto255_oprf.oprf;
272
+ const pw = utf8(password.normalize('NFKC'));
172
273
 
173
- // 1. Challenge (the server returns one even for unknown users -> no oracle).
174
- const ch = decodeChallenge(
175
- await postBinary(baseUrl, '/login/start', new DataWriter().writeString(username).toBytes()),
274
+ // 1. Blinded login/start.
275
+ const { blind, blinded } = oprf.blind(pw);
276
+ const r = await postBinary(
277
+ baseUrl,
278
+ '/login/start',
279
+ new DataWriter().writeString(username).writeBytes(blinded).toBytes(),
176
280
  );
177
-
281
+ const cid = r.readBytes();
282
+ const aud = r.readString();
283
+ const kdf = decodeKdf(r);
284
+ const nonce = r.readBytes();
285
+ const iat = r.readU64();
286
+ const exp = r.readU64();
287
+ const evaluated = r.readBytes();
178
288
  // Client-side fast-fail only; the server re-checks expiry authoritatively.
179
- if (BigInt(Math.floor(Date.now() / 1000)) >= ch.exp) throw new Error('auth: challenge expired');
289
+ if (BigInt(Math.floor(Date.now() / 1000)) >= exp) throw new Error('auth: challenge expired');
290
+
291
+ // 2. OPRF -> keyed salt -> seed.
292
+ const oprfOutput = oprf.finalize(pw, blind, evaluated);
293
+ const seed = await deriveSeed(oprfOutput, kdf);
180
294
 
181
- // 2. Build the exact message, derive, sign once, wipe.
182
- const message = buildLoginMessage(username, ch.aud, ch.cid, ch.nonce, ch.iat, ch.exp);
183
- const seed = await deriveSeed(password, ch.kdf);
295
+ // 3. Encapsulate to the pinned server KEM key, build + sign the v2 message.
296
+ const { cipherText, sharedSecret } = ml_kem768.encapsulate(SERVER_KEM_PUBLIC_KEY);
297
+ const message = buildLoginMessageV2(username, aud, cid, nonce, iat, exp, cipherText);
184
298
  let signature: Uint8Array;
185
299
  try {
186
300
  const kp = ml_dsa44.keygen(seed);
187
301
  try {
188
- signature = ml_dsa44.sign(message, kp.secretKey, { context: new TextEncoder().encode(LOGIN_CONTEXT) });
302
+ signature = ml_dsa44.sign(message, kp.secretKey, { context: utf8(LOGIN_CONTEXT) });
189
303
  } finally {
190
304
  wipe(kp.secretKey);
191
305
  }
@@ -194,15 +308,29 @@ export async function login(username: string, password: string, opts: AuthOption
194
308
  }
195
309
  if (signature.length !== SIGNATURE_LEN) throw new Error('auth: bad signature length');
196
310
 
197
- // 3. Submit {cid, signature}; the server consumes the challenge atomically,
198
- // rebuilds the message from its own stored values, and verifies.
311
+ // 4. Submit {cid, ct, signature}.
199
312
  const res = await postBinary(
200
313
  baseUrl,
201
314
  '/login/finish',
202
- new DataWriter().writeBytes(ch.cid).writeBytes(signature).toBytes(),
315
+ new DataWriter().writeBytes(cid).writeBytes(cipherText).writeBytes(signature).toBytes(),
203
316
  );
204
- if (res.readU8() !== 0) throw new Error('auth: login failed');
205
- return res.readBytes(); // session token
317
+ if (res.readU8() !== 0) {
318
+ wipe(sharedSecret);
319
+ throw new Error('auth: login failed');
320
+ }
321
+ const session = res.readBytes();
322
+ const serverConfirm = res.readBytes();
323
+
324
+ // 5. Mutual auth: only a server that decapsulated correctly can produce
325
+ // SHA-256(label || sharedSecret || sha256(M)). Verify before trusting.
326
+ const transcriptHash = await sha256Bytes(message);
327
+ const expected = await sha256Bytes(
328
+ concatBytes(utf8(SERVER_CONFIRM_LABEL), sharedSecret, transcriptHash),
329
+ );
330
+ wipe(sharedSecret);
331
+ if (!bytesEqual(expected, serverConfirm)) throw new Error('auth: server authentication failed');
332
+
333
+ return session; // session token
206
334
  }
207
335
 
208
336
  /** Lowercase hex of `bytes`. */
@@ -11,7 +11,7 @@
11
11
  export { mount } from './routing/mount.js';
12
12
  export { Router } from './routing/Router.js';
13
13
  export { Auth, register as authRegister, login as authLogin, proveIdentity, buildLoginMessage, LOGIN_CONTEXT } from './auth.js';
14
- export type { KdfParams, Challenge, AuthOptions, IdentityProof } from './auth.js';
14
+ export type { KdfParams, AuthOptions, IdentityProof } from './auth.js';
15
15
  export { Link } from './navigation/Link.js';
16
16
  export type { LinkProps } from './navigation/Link.js';
17
17
  export { NavLink, matchActive } from './navigation/NavLink.js';
@@ -122,7 +122,7 @@ export const TOIL_SERVER_ENV_DTS =
122
122
  `declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }\n` +
123
123
  `declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }\n` +
124
124
  `declare namespace TwoFactor { const DEFAULT_TTL_SECS: u64; const DEFAULT_DIGITS: i32; function setSecret(secret: Uint8Array): void; function issue(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorIssue; function send(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorChallenge; function verify(token: string, recipient: string, code: string): bool; }\n` +
125
- `declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function getUser(): __ToilAuthUser | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n` +
125
+ `declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function getUser(): __ToilAuthUser | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; const KEM_CIPHERTEXT_LEN: i32; const KEM_SECRET_KEY_LEN: i32; const KEM_PUBLIC_KEY_LEN: i32; const SHARED_SECRET_LEN: i32; const OPRF_ELEMENT_LEN: i32; const OPRF_SEED_LEN: i32; const SERVER_CONFIRM_LABEL: string; function setOprfSeed(seed: Uint8Array): void; function setServerKemSecretKey(secretKey: Uint8Array): void; function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array; function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array; function sha256(data: Uint8Array): Uint8Array; function buildLoginMessageV2(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64, ciphertext: Uint8Array): Uint8Array; function serverConfirmTag(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array; const REGISTER_CONTEXT: string; function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array; function verifyRegister(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n` +
126
126
  `interface __ToilAuthUser {}\n`;
127
127
 
128
128
  /**