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
@@ -1,18 +1,14 @@
1
- // Post-quantum identity demo, challenge-response. The browser fetches a
2
- // SERVER-issued challenge (a fresh nonce the edge HMAC-signs into a token),
3
- // derives an ML-DSA-44 keypair from a password (Argon2id, all client-side, the
4
- // password never leaves), signs the message built from the server's nonce, and
5
- // POSTs the public key + signature + token to the edge, which re-opens the token
6
- // (rejecting a forged/expired one) and verifies via `crypto.mldsa_verify`
7
- // (server/routes/PqDemo.ts). The secret key is wiped right after signing.
8
- //
9
- // The nonce is server-chosen and tamper-proof, so a client cannot pre-sign or
10
- // swap in its own. It still isn't the full production login (no single-use
11
- // consume -> within the TTL a captured proof could be replayed; that needs a
12
- // store) -- see Auth.login / server/routes/Auth.ts and docs/auth.md.
1
+ // Post-quantum auth demo, the full toil PQ-Auth chain (the password never leaves the
2
+ // tab). REGISTER: the browser blinds the password through the server-keyed OPRF,
3
+ // stretches the OPRF output with Argon2id into an ML-DSA-44 keypair, and submits
4
+ // only the public key + a proof-of-possession. LOG IN: a challenge-response that
5
+ // also runs an ML-KEM-768 encapsulation; the server proves its own identity with
6
+ // a confirmation tag the client verifies (mutual auth). On success the edge mints
7
+ // the signed `__Host-toil_sess` session. See server/routes/Auth.ts +
8
+ // server/globals/auth.ts (the AuthService global) and toiljs/client (Auth.*).
13
9
  import { useCallback, useState } from 'react';
14
10
 
15
- import { Auth, type IdentityProof } from 'toiljs/client';
11
+ import { Auth } from 'toiljs/client';
16
12
  import { Account } from 'shared/server';
17
13
 
18
14
  import { useBrowserValue } from '../lib/useBrowserValue';
@@ -53,66 +49,60 @@ interface VerifiedUser {
53
49
  export const metadata: Toil.Metadata = {
54
50
  title: 'Post-quantum auth',
55
51
  description:
56
- 'ML-DSA-44 (FIPS 204) end-to-end: the browser derives a keypair from a password (Argon2id) and signs; the edge verifies via crypto.mldsa_verify. No secret ever leaves the client.',
52
+ 'A server-keyed-salt OPRF + ML-DSA-44 (FIPS 204) auth + ML-KEM-768 (FIPS 203) mutual auth. The password never leaves the browser.',
57
53
  };
58
54
 
59
- type Result = { ok: boolean; status: number; text: string } | { error: string };
60
-
61
- async function postVerify(envelope: Uint8Array): Promise<Result> {
62
- try {
63
- const res = await fetch('/pq/verify', {
64
- method: 'POST',
65
- headers: { 'content-type': 'application/octet-stream' },
66
- body: envelope as BodyInit,
67
- });
68
- return { ok: res.ok, status: res.status, text: (await res.text()).trim() };
69
- } catch (e) {
70
- return { error: e instanceof Error ? e.message : String(e) };
71
- }
72
- }
73
-
74
- /** Flip one byte of the signature (last field) so the proof must fail. */
75
- function tamper(envelope: Uint8Array): Uint8Array {
76
- const out = envelope.slice();
77
- if (out.length > 0) out[out.length - 1] ^= 0x01;
78
- return out;
79
- }
55
+ type Note = { kind: 'ok' | 'err'; text: string } | null;
80
56
 
81
57
  export default function Pq(): React.JSX.Element {
82
58
  const [username, setUsername] = useState('ada');
83
59
  const [password, setPassword] = useState('correct horse battery staple');
84
60
  const [busy, setBusy] = useState(false);
85
- const [proof, setProof] = useState<IdentityProof | null>(null);
86
- const [result, setResult] = useState<Result | null>(null);
61
+ const [note, setNote] = useState<Note>(null);
87
62
  const [verified, setVerified] = useState<VerifiedUser | null | 'none'>(null);
88
63
 
89
64
  // Hydration-safe: null on the server and first paint, the live companion
90
65
  // cookie after mount; `refreshCompanion()` re-reads after a PQ login.
91
66
  const [companion, refreshCompanion] = useBrowserValue(readCompanion, null);
92
67
 
93
- const prove = useCallback(
94
- async (doTamper: boolean) => {
95
- setBusy(true);
96
- setResult(null);
97
- setVerified(null);
98
- try {
99
- const p = await Auth.proveIdentity(username, password);
100
- setProof(p);
101
- // A real (untampered) proof logs in: /pq/verify mints the session.
102
- const r = await postVerify(doTamper ? tamper(p.envelope) : p.envelope);
103
- setResult(r);
104
- refreshCompanion();
105
- } catch (e) {
106
- setResult({ error: e instanceof Error ? e.message : String(e) });
107
- } finally {
108
- setBusy(false);
109
- }
110
- },
111
- [username, password, refreshCompanion],
112
- );
68
+ const doRegister = useCallback(async () => {
69
+ setBusy(true);
70
+ setNote(null);
71
+ setVerified(null);
72
+ try {
73
+ await Auth.register(username, password);
74
+ setNote({
75
+ kind: 'ok',
76
+ text: 'registered: the server stored only your public key and a proof-of-possession. Now log in to run the ML-KEM-768 mutual-auth step.',
77
+ });
78
+ } catch (e) {
79
+ setNote({ kind: 'err', text: e instanceof Error ? e.message : String(e) });
80
+ } finally {
81
+ setBusy(false);
82
+ }
83
+ }, [username, password]);
84
+
85
+ const doLogin = useCallback(async () => {
86
+ setBusy(true);
87
+ setNote(null);
88
+ setVerified(null);
89
+ try {
90
+ // Resolves only if the server's mutual-auth confirmation tag verified.
91
+ await Auth.login(username, password);
92
+ setNote({
93
+ kind: 'ok',
94
+ text: 'logged in: ML-KEM-768 mutual auth verified (the server proved it holds the KEM secret key).',
95
+ });
96
+ refreshCompanion();
97
+ } catch (e) {
98
+ setNote({ kind: 'err', text: e instanceof Error ? e.message : String(e) });
99
+ } finally {
100
+ setBusy(false);
101
+ }
102
+ }, [username, password, refreshCompanion]);
113
103
 
114
- /** Hit the @auth-guarded /session/me to prove the PQ login established a
115
- * real session the server re-verifies. */
104
+ /** Hit the @auth-guarded /session/me to prove the login established a real
105
+ * session the server re-verifies. */
116
106
  const checkSession = useCallback(async () => {
117
107
  setBusy(true);
118
108
  try {
@@ -135,7 +125,7 @@ export default function Pq(): React.JSX.Element {
135
125
  await fetch('/session/logout', { method: 'POST', credentials: 'same-origin' });
136
126
  refreshCompanion();
137
127
  setVerified(null);
138
- setResult(null);
128
+ setNote(null);
139
129
  } finally {
140
130
  setBusy(false);
141
131
  }
@@ -145,11 +135,13 @@ export default function Pq(): React.JSX.Element {
145
135
  <main style={{ maxWidth: 680 }}>
146
136
  <h1>Post-quantum login</h1>
147
137
  <p>
148
- The edge issues a fresh, HMAC-signed <strong>challenge</strong> (a server-chosen nonce). The browser
149
- stretches the password with <strong>Argon2id</strong>, expands it into an <strong>ML-DSA-44</strong>{' '}
150
- (FIPS 204) keypair, and signs the message built from <em>that</em> nonce. Only the public key,
151
- signature, and the server's token are sent back; the edge re-opens the token and verifies with the{' '}
152
- <code>crypto.mldsa_verify</code> host import. The password and secret key never leave this tab.
138
+ <strong>Register</strong> blinds the password through the server-keyed <strong>OPRF</strong> (so a
139
+ breached server can&apos;t precompute a password dictionary), stretches the result with{' '}
140
+ <strong>Argon2id</strong> into an <strong>ML-DSA-44</strong> keypair, and sends only the public key plus
141
+ a proof-of-possession. <strong>Log in</strong> signs a server challenge and runs an{' '}
142
+ <strong>ML-KEM-768</strong> key encapsulation; the edge decapsulates and returns a confirmation tag the
143
+ browser verifies, so the <em>server</em> is authenticated too. The password and secret key never leave
144
+ this tab.
153
145
  </p>
154
146
 
155
147
  <div style={{ display: 'grid', gap: 8, maxWidth: 420 }}>
@@ -166,50 +158,30 @@ export default function Pq(): React.JSX.Element {
166
158
  />
167
159
  </label>
168
160
  <div style={{ display: 'flex', gap: 8 }}>
169
- <button onClick={() => prove(false)} disabled={busy}>
170
- {busy ? 'Deriving + signing…' : 'Log in'}
161
+ <button onClick={doRegister} disabled={busy}>
162
+ {busy ? 'Working…' : 'Register'}
171
163
  </button>
172
- <button onClick={() => prove(true)} disabled={busy} title="Flip a signature byte: must fail">
173
- Tamper, then verify
164
+ <button onClick={doLogin} disabled={busy}>
165
+ {busy ? 'Working…' : 'Log in'}
174
166
  </button>
175
167
  </div>
176
168
  <p style={{ fontSize: '0.8rem', opacity: 0.7, margin: 0 }}>
177
- Demo: pre-filled <code>ada</code> / <code>correct horse battery staple</code> &mdash; but any
178
- username + password works. The keypair is derived from the <strong>username AND password</strong>:
179
- Argon2id is salted with the username (<code>sha256(&quot;pq-demo|&quot; + username)</code>), so two
180
- people with the same password get different identities. Same username+password always maps to the
181
- same keypair (no signup). What a real app adds (and this stateless demo can&apos;t, without a
182
- store): binding a username to a <em>registered</em> key, so here the username is self-asserted
183
- &mdash; <code>server/routes/Auth.ts</code>.
169
+ Demo: pre-filled <code>ada</code> / <code>correct horse battery staple</code>. Register once, then
170
+ log in. A wrong password fails at login (the derived key won&apos;t match the stored one). Storage is
171
+ the DEV-only in-process KV (<code>src/devserver/kv.ts</code>); a real deployment wires an atomic
172
+ store, <code>server/routes/Auth.ts</code>.
184
173
  </p>
185
174
  </div>
186
175
 
187
- {proof && (
188
- <p style={{ marginTop: 16, fontFamily: 'monospace', fontSize: '0.85rem', opacity: 0.8 }}>
189
- server nonce {proof.nonceHex}…, Argon2id {proof.deriveMs} ms, public key {proof.publicKeyHex}…
190
- (1312 B), signature {proof.signatureLen} B
191
- </p>
192
- )}
193
-
194
- {result && (
195
- <p
196
- style={{
197
- marginTop: 8,
198
- fontWeight: 600,
199
- color: 'error' in result ? '#c0392b' : result.ok ? '#1e8449' : '#c0392b',
200
- }}
201
- >
202
- {'error' in result
203
- ? `error: ${result.error}`
204
- : `POST /pq/verify -> ${result.status}: ${result.text}`}
176
+ {note && (
177
+ <p style={{ marginTop: 8, fontWeight: 600, color: note.kind === 'ok' ? '#1e8449' : '#c0392b' }}>
178
+ {note.text}
205
179
  </p>
206
180
  )}
207
181
 
208
182
  {companion && (
209
183
  <section style={{ marginTop: 20, borderTop: '1px solid #8884', paddingTop: 16 }}>
210
- <h2 style={{ fontSize: '1.05rem' }}>
211
- Signed in &mdash; the post-quantum proof minted a session
212
- </h2>
184
+ <h2 style={{ fontSize: '1.05rem' }}>Signed in &mdash; the post-quantum login minted a session</h2>
213
185
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
214
186
  <div style={{ border: '1px solid #2563ff55', borderRadius: 8, padding: '0.6rem 0.9rem' }}>
215
187
  <strong>getUser(), client</strong>
@@ -248,12 +220,9 @@ export default function Pq(): React.JSX.Element {
248
220
  )}
249
221
 
250
222
  <p style={{ marginTop: 24, opacity: 0.7, fontSize: '0.9rem' }}>
251
- This is the full login: the password derives an ML-DSA-44 keypair client-side, the edge verifies the
252
- signature, and on success it mints the signed <code>__Host-toil_sess</code> session, so every{' '}
253
- <code>@auth</code> route (like <code>/session/me</code>) and <code>getUser()</code> now recognise you.
254
- The challenge is server-issued and tamper-proof but stateless, so it has no single-use consume yet
255
- (within the TTL a captured proof could be replayed; the atomic-consume shape is in{' '}
256
- <code>server/routes/Auth.ts</code>). Plain sessions are on the{' '}
223
+ This is the full augmented-PAKE chain: OPRF keyed salt + ML-DSA client auth + ML-KEM mutual auth, with an
224
+ atomic single-use challenge consume. The OPRF layer is classical ristretto255 (the one non-PQ piece);
225
+ auth and key agreement are post-quantum. Plain sessions are on the{' '}
257
226
  <Toil.Link href="/auth">Auth</Toil.Link> page.
258
227
  </p>
259
228
  <p>
@@ -7,6 +7,24 @@ import { Method, Request, Response, Rest, ToilHandler } from 'toiljs/server/runt
7
7
  */
8
8
  export class AppHandler extends ToilHandler {
9
9
  public handle(req: Request): Response {
10
+ // Session signing secret: set on EVERY request, BEFORE routing. The signed
11
+ // session cookie is minted in one route (auth login / session dev-login) but
12
+ // verified by the `@auth` gate in another, and each request runs in a FRESH
13
+ // wasm instance -- so a secret configured inside a single handler is absent on
14
+ // the instance that verifies `/session/me`, and the HMAC check fails (401).
15
+ // Setting it here, at the one entry point every request passes through, makes
16
+ // mint and verify always agree. Read from the env store with a clearly-insecure
17
+ // DEV fallback so the demo runs with zero config; set AUTH_SESSION_SECRET in
18
+ // `.env.secrets` for any non-throwaway use.
19
+ const sessionSecret = Environment.getSecure('AUTH_SESSION_SECRET');
20
+ AuthService.setSecret(
21
+ Uint8Array.wrap(
22
+ String.UTF8.encode(
23
+ sessionSecret != null ? sessionSecret : 'toil-demo-insecure-session-secret-change-me',
24
+ ),
25
+ ),
26
+ );
27
+
10
28
  // Rest.dispatch returns the first matching route's Response, or null if nothing
11
29
  // matched - then we fall through to our own logic. REST composes; it never takes
12
30
  // over handle().
@@ -228,10 +246,13 @@ export class AppHandler extends ToilHandler {
228
246
  return null;
229
247
  }
230
248
 
231
- // Demo signing/encryption key: 32 bytes, valid for AES-256-GCM and HMAC. A real
232
- // app loads a long random secret from config; never hard-code one.
249
+ // Demo signing/encryption key, valid for AES-256-GCM and HMAC. Read from the
250
+ // env store (`Environment.getSecure`, backed by `.env.secrets`), hashed to 32
251
+ // bytes so any value works; falls back to a DEV default so the demo runs with
252
+ // zero config. A real app sets DEMO_COOKIE_KEY in `.env.secrets`.
233
253
  private demoKey(): Uint8Array {
234
- return Uint8Array.wrap(String.UTF8.encode('0123456789abcdef0123456789abcdef'));
254
+ const k = Environment.getSecure('DEMO_COOKIE_KEY');
255
+ return crypto.sha256Text(k != null ? k : '0123456789abcdef0123456789abcdef');
235
256
  }
236
257
 
237
258
  /** The `Set-Cookie` string `clearCookie(name)` emits, for display. */
@@ -10,7 +10,6 @@ import './routes/Auth';
10
10
  import './routes/Players';
11
11
  import './routes/Leaderboard';
12
12
  import './routes/Session';
13
- import './routes/PqDemo';
14
13
  import './routes/EnvDemo';
15
14
  import './services/Stats';
16
15
  import './services/remotes';