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
@@ -11,13 +11,41 @@
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, createSHA256, createHMAC } 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 separators for the session-key derivation and the confirmation tag.
26
+ * Byte-identical to the server. */
27
+ export const SESSION_KEY_LABEL = 'toil-session-key-v1';
28
+ export const SERVER_CONFIRM_LABEL = 'toil-server-confirm-v1';
29
+
30
+ /** ML-KEM-768 sizes (FIPS 203). */
31
+ export const KEM_PUBLIC_KEY_LEN = 1184;
32
+ export const KEM_CIPHERTEXT_LEN = 1088;
33
+ export const SHARED_SECRET_LEN = 32;
34
+
35
+ /** Lowercase-hex -> bytes. */
36
+ function fromHex(hex: string): Uint8Array {
37
+ const out = new Uint8Array(hex.length / 2);
38
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * The server's PINNED static ML-KEM-768 public key. The client encapsulates to
44
+ * it; only the genuine server (holder of the matching secret key) can
45
+ * decapsulate, so a valid confirmation tag authenticates the server. This is
46
+ * the demo dev key; a real deployment pins its own (and rotates it).
47
+ */
48
+ export const SERVER_KEM_PUBLIC_KEY = fromHex('29d765e8083182891302569b3712a856e564fdd484b0706b0c68568d5ab7edc742cf74459d64595455a60f267973aa55e43c5be61925a3822eafcca445e36dc4655636e31e6fc9bec338b253f94290008ef7f40dbddb49c15c690f6755a23a1b3c85cfd5207e71a607086a6fc6d74a05080f43276901a19cafdb8de7771d58ea07f0f1056b905127b22223d08e75173199f13ab13c5dcd3b51ac784f84e520484a262b845a897c41cf27324ab6ba545c78c9ccab361051e0bba53498af26240fa0d566d1572684f4b42e253e6d052c848650915063c35641e1121ef8d9cfd17b667b351103c56d195007c9376d0c08aa268396814490eab4c364175a94533267a1933862cc4c33bcf0a13d1fa2b9d6c5082eeca1480672f2526cbe013beff14dc908a386e0b633c8761023cbed760deac6709bc328d865ac82e12307b673d96711dbb27a4d939230d25b53d594169a318be0200fa33550e9418e2a3b30e9719edc09d5fc4306f1abfd021eab14637a8a72c5931d25dc9b56db0e6ab677522b10f25307dbb804a6774ce05b87b0976a4b227bfe6caf20a79e64004fbd27b1eea018b3ab8ffa629f2dc87f19278f95168e94e44660a3370c537795678eb2f056260609769740583b51b291862927a1938737c6a37f40b78f00671cccbcb88ac3427b37915ed58782998f84051647707d48995472baad3f64a7cca54e1c0734db08751c614a34f28b84f2c1b5a6817355ab61957c486b7acffbc092bc8a7b46387f33b53ed372f7168d31a71cd008539928b0cdf91e835aa97f6a2be6d327b87a6ae478701d75a59a25179cb14997bb2552853014724170a1c49b82c2bcebc3279024e1fa44c53c7afdc43f0bd22116490f3b74c90e7296be58b9a91168f2fa0c3d378a3bcac959f357825c9976a8c9ee944f29b45e96d7345d9b478431a20cf1c5d3a3227c717fd204619777636c0cb140db5c50d2a3302334461030bee34e4eb1a6f02b733f9ccda4290fa168bc039568373241542728d00030d1f251e83737cb215adbdc1de75978675a0cd0d75b12748abdda7a9852629c63697d145af2c69854b06e03f37c4b064e4c9a4c03f2ad4d081e70180e9547247921918118086b62b4f7727f46b24e3e79ba3f28209f32b5102035bf935856232f83642268c0292ec6bf8e9462382163d30a20b4bcb7b4439310ec9d0a148193907fc07697342967cf1a16c6b3c71558951fa915400736cf699262b54b723abb2ecc27b74b68ee494287595ef818388adb49e883c67bfa5c226c0eef037a0851a29d34675912c1ea1068310b6dfcd017c809c8fbfc2c3ae78dfef07299960eeefba182662a90fa422c1790f356a2ea909012b15623a9b9e450a282cb530589a68368b3583159d9010ac3e52cc974753c342e58279516339dfb691df94b13a223ad97eb6a09c21dafe6304a3642d6d2067b5238497661fe88ad1227ca3557be2a576b6e17c5a7f997ea07929e76407e376aba74c44cd8504804776f39bbb8327624188a63501e83b404d9438cade0b11dc3ac61856447fb072b91761c228878f01b2eb6b4b21ba664c2c75882431603b25a449ffeb8410b910558581777562aa9b2181fd9c04713ad9326462d3e842121c4997f9aa932417c67851625816de66e0d65637434629f39');
21
49
 
22
50
  export const PUBLIC_KEY_LEN = 1312;
23
51
  export const SECRET_KEY_LEN = 2560;
@@ -38,16 +66,6 @@ export interface KdfParams {
38
66
  readonly salt: Uint8Array;
39
67
  }
40
68
 
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
69
  /** Overwrite a secret buffer with random bytes, then zero. Best-effort: JS GC
52
70
  * cannot scrub copies, so we never store or close over secrets beyond one call. */
53
71
  function wipe(buf: Uint8Array): void {
@@ -55,10 +73,13 @@ function wipe(buf: Uint8Array): void {
55
73
  buf.fill(0);
56
74
  }
57
75
 
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> {
76
+ /** Argon2id(oprfOutput, salt; m,t,p, len=32) -> 32-byte ML-DSA seed. The KDF
77
+ * input is the OPRF output (the keyed salt), NOT the raw password: the password
78
+ * is first run through the server-keyed OPRF, so a server breach yields no
79
+ * precomputable salt. */
80
+ async function deriveSeed(oprfOutput: Uint8Array, kdf: KdfParams): Promise<Uint8Array> {
60
81
  return argon2id({
61
- password: new TextEncoder().encode(password.normalize('NFKC')),
82
+ password: oprfOutput,
62
83
  salt: kdf.salt,
63
84
  iterations: kdf.iterations,
64
85
  parallelism: kdf.parallelism,
@@ -68,8 +89,52 @@ async function deriveSeed(password: string, kdf: KdfParams): Promise<Uint8Array>
68
89
  });
69
90
  }
70
91
 
71
- /** The canonical login message `M`, fixed binary layout (see the server's
72
- * `AuthService.buildLoginMessage`). Both ends MUST produce identical bytes. */
92
+ const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s);
93
+
94
+ /** SHA-256 over `data` -> 32 raw bytes. Uses hash-wasm (pure WASM), so it works
95
+ * in an insecure context (plain HTTP) where `crypto.subtle` is undefined. */
96
+ async function sha256Bytes(data: Uint8Array): Promise<Uint8Array> {
97
+ const h = await createSHA256();
98
+ h.init();
99
+ h.update(data);
100
+ return h.digest('binary') as unknown as Uint8Array;
101
+ }
102
+
103
+ /** HMAC-SHA256(key, msg) -> 32 raw bytes, via hash-wasm (pure WASM, works in an
104
+ * insecure context). Mirrors the server's `AuthService` HMAC for the session-key
105
+ * derivation and the mutual-auth confirmation tag. */
106
+ async function hmacSha256(key: Uint8Array, msg: Uint8Array): Promise<Uint8Array> {
107
+ const h = await createHMAC(createSHA256(), key);
108
+ h.init();
109
+ h.update(msg);
110
+ return h.digest('binary') as unknown as Uint8Array;
111
+ }
112
+
113
+ function concatBytes(...parts: Uint8Array[]): Uint8Array {
114
+ let n = 0;
115
+ for (const p of parts) n += p.length;
116
+ const out = new Uint8Array(n);
117
+ let off = 0;
118
+ for (const p of parts) {
119
+ out.set(p, off);
120
+ off += p.length;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** Length-checked constant-time-ish equality (no early-exit on content). */
126
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
127
+ if (a.length !== b.length) return false;
128
+ let diff = 0;
129
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
130
+ return diff === 0;
131
+ }
132
+
133
+ /** The canonical login message `M` -- ONE fixed binary layout, byte-identical to
134
+ * the server's `AuthService.buildLoginMessage`. Binds the ML-KEM ciphertext (so
135
+ * the signature commits to the key encapsulation), the Argon2id params (so a
136
+ * MITM cannot slip a downgrade past the signature), and the server KEM key id
137
+ * (so it commits to which server key was encapsulated to). */
73
138
  export function buildLoginMessage(
74
139
  sub: string,
75
140
  aud: string,
@@ -77,6 +142,11 @@ export function buildLoginMessage(
77
142
  nonce: Uint8Array,
78
143
  iat: bigint,
79
144
  exp: bigint,
145
+ ciphertext: Uint8Array,
146
+ memKiB: number,
147
+ iterations: number,
148
+ parallelism: number,
149
+ serverKemKeyId: Uint8Array,
80
150
  ): Uint8Array {
81
151
  return new DataWriter()
82
152
  .writeU8(1)
@@ -86,10 +156,21 @@ export function buildLoginMessage(
86
156
  .writeBytes(nonce)
87
157
  .writeU64(iat)
88
158
  .writeU64(exp)
159
+ .writeBytes(ciphertext)
160
+ .writeU32(memKiB)
161
+ .writeU32(iterations)
162
+ .writeU32(parallelism)
163
+ .writeBytes(serverKemKeyId)
89
164
  .toBytes();
90
165
  }
91
166
 
92
- // ---- wire codecs (the example `Auth` @rest controller mirrors these) -------
167
+ /** Registration proof-of-possession message: `u8(1) str(username) bytes(pk)`,
168
+ * signed under {@link REGISTER_CONTEXT}. Byte-identical to the server's
169
+ * `buildRegisterMessage`. */
170
+ export function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array {
171
+ return new DataWriter().writeU8(1).writeString(username).writeBytes(publicKey).toBytes();
172
+ }
173
+
93
174
 
94
175
  function decodeKdf(r: DataReader): KdfParams {
95
176
  return {
@@ -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,114 @@ 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. 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
- if (finish.readU8() !== 0) throw new Error('auth: registration rejected');
252
+ const finishStatus = finish.readU8();
253
+ if (finishStatus === 1) throw new Error('auth: username already registered (log in instead)');
254
+ if (finishStatus !== 0) throw new Error('auth: registration rejected');
161
255
  }
162
256
 
163
257
  /**
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.
258
+ * Log in (challenge-response with ML-KEM mutual auth). Steps:
259
+ * 1. Blind the password; `login/start` returns the challenge + the OPRF
260
+ * evaluation (a fully-formed response even for unknown users -> no oracle).
261
+ * 2. Finalize the OPRF -> keyed salt -> seed -> ML-DSA keypair.
262
+ * 3. Encapsulate a shared secret to the PINNED server ML-KEM public key, build
263
+ * the v2 message (which binds the ciphertext), and sign it once.
264
+ * 4. Submit `{cid, ct, signature}`; the server consumes the challenge, verifies
265
+ * the signature, decapsulates, and returns a confirmation tag.
266
+ * 5. Verify that tag against our own shared secret -> the server proved it
267
+ * holds the KEM secret key (mutual authentication).
268
+ * The secret key, seed, and shared secret are wiped as soon as they are used.
269
+ * Returns the opaque session token. Throws (one generic message) on any failure.
169
270
  */
170
271
  export async function login(username: string, password: string, opts: AuthOptions = {}): Promise<Uint8Array> {
171
272
  const baseUrl = opts.baseUrl ?? '/auth';
273
+ const oprf = ristretto255_oprf.oprf;
274
+ const pw = utf8(password.normalize('NFKC'));
172
275
 
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()),
276
+ // 1. Blinded login/start.
277
+ const { blind, blinded } = oprf.blind(pw);
278
+ const r = await postBinary(
279
+ baseUrl,
280
+ '/login/start',
281
+ new DataWriter().writeString(username).writeBytes(blinded).toBytes(),
176
282
  );
177
-
283
+ const cid = r.readBytes();
284
+ const aud = r.readString();
285
+ const kdf = decodeKdf(r);
286
+ const nonce = r.readBytes();
287
+ const iat = r.readU64();
288
+ const exp = r.readU64();
289
+ const evaluated = r.readBytes();
178
290
  // 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');
180
-
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);
291
+ if (BigInt(Math.floor(Date.now() / 1000)) >= exp) throw new Error('auth: challenge expired');
292
+
293
+ // 2. OPRF -> keyed salt -> seed.
294
+ const oprfOutput = oprf.finalize(pw, blind, evaluated);
295
+ const seed = await deriveSeed(oprfOutput, kdf);
296
+
297
+ // 3. Encapsulate to the pinned server KEM key; build + sign the message,
298
+ // which binds the ciphertext, the KDF params, and the server key id.
299
+ const { cipherText, sharedSecret } = ml_kem768.encapsulate(SERVER_KEM_PUBLIC_KEY);
300
+ const serverKemKeyId = await sha256Bytes(SERVER_KEM_PUBLIC_KEY);
301
+ const message = buildLoginMessage(
302
+ username, aud, cid, nonce, iat, exp,
303
+ cipherText, kdf.memKiB, kdf.iterations, kdf.parallelism, serverKemKeyId,
304
+ );
184
305
  let signature: Uint8Array;
185
306
  try {
186
307
  const kp = ml_dsa44.keygen(seed);
187
308
  try {
188
- signature = ml_dsa44.sign(message, kp.secretKey, { context: new TextEncoder().encode(LOGIN_CONTEXT) });
309
+ signature = ml_dsa44.sign(message, kp.secretKey, { context: utf8(LOGIN_CONTEXT) });
189
310
  } finally {
190
311
  wipe(kp.secretKey);
191
312
  }
@@ -194,134 +315,31 @@ export async function login(username: string, password: string, opts: AuthOption
194
315
  }
195
316
  if (signature.length !== SIGNATURE_LEN) throw new Error('auth: bad signature length');
196
317
 
197
- // 3. Submit {cid, signature}; the server consumes the challenge atomically,
198
- // rebuilds the message from its own stored values, and verifies.
318
+ // 4. Submit {cid, ct, signature}.
199
319
  const res = await postBinary(
200
320
  baseUrl,
201
321
  '/login/finish',
202
- new DataWriter().writeBytes(ch.cid).writeBytes(signature).toBytes(),
322
+ new DataWriter().writeBytes(cid).writeBytes(cipherText).writeBytes(signature).toBytes(),
203
323
  );
204
- if (res.readU8() !== 0) throw new Error('auth: login failed');
205
- return res.readBytes(); // session token
206
- }
207
-
208
- /** Lowercase hex of `bytes`. */
209
- function toHex(bytes: Uint8Array): string {
210
- let s = '';
211
- for (const b of bytes) s += b.toString(16).padStart(2, '0');
212
- return s;
213
- }
214
-
215
- /** The signed identity proof produced by {@link proveIdentity}. */
216
- export interface IdentityProof {
217
- /** The wire envelope to POST to `/pq/verify`: `str(sub) str(token)
218
- * bytes(publicKey) bytes(signature)`, where `token` is the edge's
219
- * HMAC-signed challenge. The server re-opens the token, rebuilds the login
220
- * message from the values inside it, and `AuthService.verifyLogin`s it. */
221
- readonly envelope: Uint8Array;
222
- /** First bytes of the 1312-byte ML-DSA-44 public key, for display. */
223
- readonly publicKeyHex: string;
224
- /** First bytes of the SERVER-issued nonce that was signed, for display. */
225
- readonly nonceHex: string;
226
- /** Signature length (always 2420 for ML-DSA-44), for display. */
227
- readonly signatureLen: number;
228
- /** Argon2id wall-clock spent deriving the keypair, ms (for display). */
229
- readonly deriveMs: number;
230
- }
231
-
232
- /** Deterministic 16-byte Argon2id salt for the demo, so the same
233
- * username + password always maps to the same identity (keypair).
234
- * Uses hash-wasm's SHA-256 (pure WebAssembly), not `crypto.subtle`, so it
235
- * works in an insecure context (plain HTTP), where `crypto.subtle` is
236
- * undefined. */
237
- async function demoSalt(username: string): Promise<Uint8Array> {
238
- const hex = await sha256('pq-demo|' + username);
239
- const out = new Uint8Array(16);
240
- for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
241
- return out;
242
- }
243
-
244
- /**
245
- * DEMO helper: run the full post-quantum challenge-response in the browser.
246
- * Fetches a SERVER-issued challenge (`GET {baseUrl}/challenge`), stretches the
247
- * password with Argon2id into an ML-DSA-44 keypair, signs the login message
248
- * built from the SERVER's nonce/cid/iat/exp, and returns the wire envelope the
249
- * edge verifies (`AuthService.verifyLogin`). The secret key and seed are wiped
250
- * before returning; only the public key + signature leave the tab.
251
- *
252
- * The nonce is server-chosen and tamper-proof (the challenge token is
253
- * HMAC-signed by the edge), so a client cannot pre-sign or substitute its own.
254
- * It is still NOT the full production login -- there is no single-use consume, so
255
- * within the challenge TTL a captured proof could be replayed; that needs an
256
- * atomic store (see {@link login} and server/routes/Auth.ts). Demo-light
257
- * Argon2id params (16 MiB / 2 passes) keep it responsive in a tab; a real
258
- * deployment uses >= 256 MiB.
259
- */
260
- export async function proveIdentity(
261
- username: string,
262
- password: string,
263
- opts: { baseUrl?: string } = {},
264
- ): Promise<IdentityProof> {
265
- const baseUrl = opts.baseUrl ?? '/pq';
266
-
267
- // 1. Server-issued challenge: aud, cid, nonce, iat, exp, and the signed token.
268
- const cres = await fetch(baseUrl + '/challenge', { credentials: 'same-origin' });
269
- if (!cres.ok) throw new Error('pq: challenge request failed');
270
- const cr = new DataReader(new Uint8Array(await cres.arrayBuffer()));
271
- const aud = cr.readString();
272
- const cid = cr.readBytes();
273
- const nonce = cr.readBytes();
274
- const iat = cr.readU64();
275
- const exp = cr.readU64();
276
- const token = cr.readString();
277
-
278
- // 2. Derive the keypair and sign the message built from the SERVER's values.
279
- const salt = await demoSalt(username);
280
- const t0 = Date.now();
281
- const seed = await argon2id({
282
- password: new TextEncoder().encode(password.normalize('NFKC')),
283
- salt,
284
- iterations: 2,
285
- parallelism: 1,
286
- memorySize: 16 * 1024, // 16 MiB: demo-light, responsive in a tab
287
- hashLength: SEED_LEN,
288
- outputType: 'binary',
289
- });
290
- const deriveMs = Date.now() - t0;
291
-
292
- const message = buildLoginMessage(username, aud, cid, nonce, iat, exp);
293
- let publicKey: Uint8Array;
294
- let signature: Uint8Array;
295
- try {
296
- const kp = ml_dsa44.keygen(seed);
297
- publicKey = kp.publicKey;
298
- try {
299
- signature = ml_dsa44.sign(message, kp.secretKey, {
300
- context: new TextEncoder().encode(LOGIN_CONTEXT),
301
- });
302
- } finally {
303
- wipe(kp.secretKey);
304
- }
305
- } finally {
306
- wipe(seed);
324
+ if (res.readU8() !== 0) {
325
+ wipe(sharedSecret);
326
+ throw new Error('auth: login failed');
307
327
  }
308
-
309
- // 3. Envelope: sub + the server's token + the public key + the signature.
310
- const envelope = new DataWriter()
311
- .writeString(username)
312
- .writeString(token)
313
- .writeBytes(publicKey)
314
- .writeBytes(signature)
315
- .toBytes();
316
-
317
- return {
318
- envelope,
319
- publicKeyHex: toHex(publicKey.slice(0, 16)),
320
- nonceHex: toHex(nonce.slice(0, 16)),
321
- signatureLen: signature.length,
322
- deriveMs,
323
- };
328
+ const session = res.readBytes();
329
+ const serverConfirm = res.readBytes();
330
+
331
+ // 5. Mutual auth: derive the session key K = HMAC(sharedSecret, label || H(M)),
332
+ // then check the server's tag = HMAC(K, label || H(M)). Only a server that
333
+ // decapsulated correctly derives the same K, so a valid tag proves its
334
+ // identity. Verify before returning the session.
335
+ const transcriptHash = await sha256Bytes(message);
336
+ const sessionKey = await hmacSha256(sharedSecret, concatBytes(utf8(SESSION_KEY_LABEL), transcriptHash));
337
+ wipe(sharedSecret);
338
+ const expected = await hmacSha256(sessionKey, concatBytes(utf8(SERVER_CONFIRM_LABEL), transcriptHash));
339
+ if (!bytesEqual(expected, serverConfirm)) throw new Error('auth: server authentication failed');
340
+
341
+ return session; // session token
324
342
  }
325
343
 
326
344
  /** The client auth surface, grouped for `Auth.register` / `Auth.login` use. */
327
- export const Auth = { register, login, proveIdentity, buildLoginMessage, LOGIN_CONTEXT } as const;
345
+ export const Auth = { register, login, buildLoginMessage, LOGIN_CONTEXT } as const;
@@ -10,8 +10,8 @@
10
10
 
11
11
  export { mount } from './routing/mount.js';
12
12
  export { Router } from './routing/Router.js';
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';
13
+ export { Auth, register as authRegister, login as authLogin, buildLoginMessage, LOGIN_CONTEXT } from './auth.js';
14
+ export type { KdfParams, AuthOptions } 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, ciphertext: Uint8Array, memKiB: u32, iterations: u32, parallelism: u32, serverKemKeyId: Uint8Array): 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 SESSION_KEY_LABEL: string; const SERVER_CONFIRM_LABEL: string; function setOprfSeed(seed: Uint8Array): void; function setServerKemSecretKey(secretKey: Uint8Array): void; function setServerKemPublicKey(publicKey: Uint8Array): void; function serverKemKeyId(): Uint8Array; function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array; function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array; function sha256(data: Uint8Array): Uint8Array; function deriveSessionKey(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array; function serverConfirmTag(sessionKey: 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
  /**
@@ -18,6 +18,8 @@
18
18
  import * as nodeCrypto from 'node:crypto';
19
19
 
20
20
  import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
21
+ import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
22
+ import { ristretto255_oprf } from '@noble/curves/ed25519.js';
21
23
 
22
24
  import type { MemoryRef } from './host.js';
23
25
 
@@ -234,6 +236,58 @@ export function buildCryptoImports(
234
236
  return -1;
235
237
  }
236
238
  },
239
+
240
+ // ML-KEM-768 (FIPS 203) decapsulation for the mutual-auth + session-key
241
+ // layer. Mirrors the edge host (`mlkem_decapsulate_import.rs` / fips203):
242
+ // recover the 32-byte shared secret from the client's ciphertext using
243
+ // the server's static secret key, write it to `outPtr`, return 0 / neg.
244
+ // Backed by the same noble lib the client encapsulates with (dev == prod).
245
+ 'crypto.mlkem_decapsulate': (
246
+ ctPtr: number, ctLen: number,
247
+ skPtr: number, skLen: number,
248
+ outPtr: number,
249
+ ): number => {
250
+ if (ctLen !== 1088 || skLen !== 2400) return -4;
251
+ try {
252
+ const ct = new Uint8Array(readBytes(ref, ctPtr, ctLen));
253
+ const sk = new Uint8Array(readBytes(ref, skPtr, skLen));
254
+ const ss = ml_kem768.decapsulate(ct, sk); // 32 bytes; implicit rejection on bad ct
255
+ writeBytes(ref, outPtr, Buffer.from(ss));
256
+ return 0;
257
+ } catch {
258
+ return -5;
259
+ }
260
+ },
261
+
262
+ // RFC 9497 OPRF (mode 0x00, ristretto255-SHA512) server evaluation for
263
+ // the keyed-salt OPRF. Mirrors the edge host
264
+ // (`voprf_evaluate_import.rs` / the `voprf` crate): derive the per-user
265
+ // key from (seed, info=username) and blind-evaluate the client's blinded
266
+ // element, writing the 32-byte evaluated element to `outPtr`. Backed by
267
+ // `@noble/curves` ristretto255_oprf, which matches the edge byte-for-byte
268
+ // (both RFC 9497), so dev == prod.
269
+ 'crypto.voprf_evaluate': (
270
+ seedPtr: number, seedLen: number,
271
+ infoPtr: number, infoLen: number,
272
+ blindedPtr: number, blindedLen: number,
273
+ outPtr: number,
274
+ ): number => {
275
+ // seedLen MUST be exactly 32 (RFC 9497 Ns; noble deriveKeyPair rejects
276
+ // other lengths) -- matching the edge so dev and prod never diverge.
277
+ if (blindedLen !== 32 || seedLen !== 32 || infoLen > 512) return -4;
278
+ try {
279
+ const seed = new Uint8Array(readBytes(ref, seedPtr, seedLen));
280
+ const info = new Uint8Array(readBytes(ref, infoPtr, infoLen));
281
+ const blinded = new Uint8Array(readBytes(ref, blindedPtr, blindedLen));
282
+ const oprf = ristretto255_oprf.oprf;
283
+ const kp = oprf.deriveKeyPair(seed, info);
284
+ const evaluated = oprf.blindEvaluate(kp.secretKey, blinded); // 32-byte element
285
+ writeBytes(ref, outPtr, Buffer.from(evaluated));
286
+ return 0;
287
+ } catch {
288
+ return -5;
289
+ }
290
+ },
237
291
  };
238
292
  }
239
293
 
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { buildCryptoImports, freshCryptoState, type CryptoState } from './crypto.js';
21
+ import { buildKvImports } from './kv.js';
21
22
  import { EmailStatus, getEmailService } from './email/index.js';
22
23
  import { parseEmailBlob } from './email/wire.js';
23
24
  import { devEnvGet, devEnvGetSecure } from './env.js';
@@ -268,6 +269,11 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
268
269
  // Web Crypto host functions (`env.crypto.*`), backed by Node's
269
270
  // `crypto`. The dev server skips metering, so these charge nothing.
270
271
  ...buildCryptoImports(ref, state.crypto),
272
+
273
+ // DEV-ONLY persistent KV (`env.kv.*`). REMOVE LATER — scaffolding so
274
+ // the auth example's register/login chain spans requests under
275
+ // `toiljs dev`; not present on the production edge (see ./kv.ts).
276
+ ...buildKvImports(ref),
271
277
  },
272
278
  };
273
279
  }