toiljs 0.0.50 → 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.
@@ -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, full OPAQUE-style 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,54 @@ 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
+ 'OPAQUE-style: an OPRF keyed salt + 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({ kind: 'ok', text: 'registered — the server stored only your public key + PoP. Now log in.' });
75
+ } catch (e) {
76
+ setNote({ kind: 'err', text: e instanceof Error ? e.message : String(e) });
77
+ } finally {
78
+ setBusy(false);
79
+ }
80
+ }, [username, password]);
81
+
82
+ const doLogin = useCallback(async () => {
83
+ setBusy(true);
84
+ setNote(null);
85
+ setVerified(null);
86
+ try {
87
+ // Resolves only if the server's mutual-auth confirmation tag verified.
88
+ await Auth.login(username, password);
89
+ setNote({ kind: 'ok', text: 'logged in — mutual auth verified (server proved it holds the KEM key).' });
90
+ refreshCompanion();
91
+ } catch (e) {
92
+ setNote({ kind: 'err', text: e instanceof Error ? e.message : String(e) });
93
+ } finally {
94
+ setBusy(false);
95
+ }
96
+ }, [username, password, refreshCompanion]);
113
97
 
114
- /** Hit the @auth-guarded /session/me to prove the PQ login established a
115
- * real session the server re-verifies. */
98
+ /** Hit the @auth-guarded /session/me to prove the login established a real
99
+ * session the server re-verifies. */
116
100
  const checkSession = useCallback(async () => {
117
101
  setBusy(true);
118
102
  try {
@@ -135,7 +119,7 @@ export default function Pq(): React.JSX.Element {
135
119
  await fetch('/session/logout', { method: 'POST', credentials: 'same-origin' });
136
120
  refreshCompanion();
137
121
  setVerified(null);
138
- setResult(null);
122
+ setNote(null);
139
123
  } finally {
140
124
  setBusy(false);
141
125
  }
@@ -143,13 +127,15 @@ export default function Pq(): React.JSX.Element {
143
127
 
144
128
  return (
145
129
  <main style={{ maxWidth: 680 }}>
146
- <h1>Post-quantum login</h1>
130
+ <h1>Post-quantum login (OPAQUE-style)</h1>
147
131
  <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.
132
+ <strong>Register</strong> blinds the password through the server-keyed <strong>OPRF</strong> (so a
133
+ breached server can&apos;t precompute a password dictionary), stretches the result with{' '}
134
+ <strong>Argon2id</strong> into an <strong>ML-DSA-44</strong> keypair, and sends only the public key plus
135
+ a proof-of-possession. <strong>Log in</strong> signs a server challenge and runs an{' '}
136
+ <strong>ML-KEM-768</strong> key encapsulation; the edge decapsulates and returns a confirmation tag the
137
+ browser verifies, so the <em>server</em> is authenticated too. The password and secret key never leave
138
+ this tab.
153
139
  </p>
154
140
 
155
141
  <div style={{ display: 'grid', gap: 8, maxWidth: 420 }}>
@@ -166,50 +152,30 @@ export default function Pq(): React.JSX.Element {
166
152
  />
167
153
  </label>
168
154
  <div style={{ display: 'flex', gap: 8 }}>
169
- <button onClick={() => prove(false)} disabled={busy}>
170
- {busy ? 'Deriving + signing…' : 'Log in'}
155
+ <button onClick={doRegister} disabled={busy}>
156
+ {busy ? 'Working…' : 'Register'}
171
157
  </button>
172
- <button onClick={() => prove(true)} disabled={busy} title="Flip a signature byte: must fail">
173
- Tamper, then verify
158
+ <button onClick={doLogin} disabled={busy}>
159
+ {busy ? 'Working…' : 'Log in'}
174
160
  </button>
175
161
  </div>
176
162
  <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>.
163
+ Demo: pre-filled <code>ada</code> / <code>correct horse battery staple</code>. Register once, then
164
+ log in. A wrong password fails at login (the derived key won&apos;t match the stored one). Storage is
165
+ the DEV-only in-process KV (<code>src/devserver/kv.ts</code>); a real deployment wires an atomic
166
+ store <code>server/routes/Auth.ts</code>.
184
167
  </p>
185
168
  </div>
186
169
 
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}`}
170
+ {note && (
171
+ <p style={{ marginTop: 8, fontWeight: 600, color: note.kind === 'ok' ? '#1e8449' : '#c0392b' }}>
172
+ {note.text}
205
173
  </p>
206
174
  )}
207
175
 
208
176
  {companion && (
209
177
  <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>
178
+ <h2 style={{ fontSize: '1.05rem' }}>Signed in &mdash; the post-quantum login minted a session</h2>
213
179
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
214
180
  <div style={{ border: '1px solid #2563ff55', borderRadius: 8, padding: '0.6rem 0.9rem' }}>
215
181
  <strong>getUser(), client</strong>
@@ -248,12 +214,9 @@ export default function Pq(): React.JSX.Element {
248
214
  )}
249
215
 
250
216
  <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{' '}
217
+ This is the full augmented-PAKE chain: OPRF keyed salt + ML-DSA client auth + ML-KEM mutual auth, with an
218
+ atomic single-use challenge consume. The OPRF layer is classical ristretto255 (the one non-PQ piece);
219
+ auth and key agreement are post-quantum. Plain sessions are on the{' '}
257
220
  <Toil.Link href="/auth">Auth</Toil.Link> page.
258
221
  </p>
259
222
  <p>