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.
- package/CHANGELOG.md +19 -0
- package/TYPESCRIPT_LAW.md +12601 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +16 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +9 -20
- package/build/client/auth.js +112 -95
- package/build/client/index.d.ts +2 -2
- package/build/client/index.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +1 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +33 -0
- package/build/devserver/host.js +2 -0
- package/build/devserver/kv.d.ts +3 -0
- package/build/devserver/kv.js +53 -0
- package/build/devserver/module.js +2 -1
- package/docs/auth-todo.md +149 -0
- package/docs/auth.md +234 -173
- package/examples/basic/client/routes/pq.tsx +72 -103
- package/examples/basic/server/core/AppHandler.ts +24 -3
- package/examples/basic/server/main.ts +0 -1
- package/examples/basic/server/routes/Auth.ts +304 -99
- package/examples/basic/server/routes/Session.ts +5 -2
- package/package.json +2 -1
- package/server/globals/auth.ts +263 -10
- package/src/cli/diagnostics.ts +22 -0
- package/src/cli/doctor.ts +2 -0
- package/src/client/auth.ts +192 -174
- package/src/client/index.ts +2 -2
- package/src/compiler/generate.ts +1 -1
- package/src/devserver/crypto.ts +54 -0
- package/src/devserver/host.ts +6 -0
- package/src/devserver/kv.ts +93 -0
- package/src/devserver/module.ts +4 -1
- package/test/devserver-pqauth.test.ts +153 -0
- package/test/doctor.test.ts +22 -0
- package/test/pqauth-e2e.test.ts +207 -0
- package/examples/basic/server/routes/PqDemo.ts +0 -127
|
@@ -1,18 +1,14 @@
|
|
|
1
|
-
// Post-quantum
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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)
|
|
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
|
|
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 [
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
115
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<
|
|
138
|
+
<strong>Register</strong> blinds the password through the server-keyed <strong>OPRF</strong> (so a
|
|
139
|
+
breached server can'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={
|
|
170
|
-
{busy ? '
|
|
161
|
+
<button onClick={doRegister} disabled={busy}>
|
|
162
|
+
{busy ? 'Working…' : 'Register'}
|
|
171
163
|
</button>
|
|
172
|
-
<button onClick={
|
|
173
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
same keypair (no signup). What a real app adds (and this stateless demo can't, without a
|
|
182
|
-
store): binding a username to a <em>registered</em> key, so here the username is self-asserted
|
|
183
|
-
— <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'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
|
-
{
|
|
188
|
-
<p style={{ marginTop:
|
|
189
|
-
|
|
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 — the post-quantum proof minted a session
|
|
212
|
-
</h2>
|
|
184
|
+
<h2 style={{ fontSize: '1.05rem' }}>Signed in — 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
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
232
|
-
//
|
|
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
|
-
|
|
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. */
|