toiljs 0.0.34 → 0.0.36
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 +10 -0
- package/README.md +1 -0
- package/as-pect.config.js +8 -2
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +97 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +42 -0
- package/build/client/auth.js +179 -0
- package/build/client/index.d.ts +5 -1
- package/build/client/index.js +3 -1
- package/build/client/routing/loader.d.ts +1 -0
- package/build/client/routing/loader.js +37 -0
- package/build/client/routing/mount.js +32 -1
- package/build/client/ssr/markers.d.ts +34 -0
- package/build/client/ssr/markers.js +49 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/docs.js +88 -1
- package/build/compiler/generate.d.ts +2 -0
- package/build/compiler/generate.js +2 -2
- package/build/compiler/index.js +2 -0
- package/build/compiler/ssr-codegen.d.ts +2 -0
- package/build/compiler/ssr-codegen.js +36 -0
- package/build/compiler/template-build.d.ts +29 -0
- package/build/compiler/template-build.js +150 -0
- package/build/compiler/template.d.ts +22 -0
- package/build/compiler/template.js +169 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +15 -0
- package/build/devserver/host.js +1 -0
- package/build/devserver/module.d.ts +1 -0
- package/build/devserver/module.js +23 -1
- package/docs/README.md +56 -0
- package/docs/auth.md +261 -0
- package/docs/caching.md +115 -0
- package/docs/cookies.md +457 -0
- package/docs/crypto.md +130 -0
- package/docs/data.md +131 -0
- package/docs/getting-started.md +128 -0
- package/docs/routing.md +259 -0
- package/docs/rpc.md +149 -0
- package/docs/ssr.md +184 -0
- package/docs/time.md +43 -0
- package/examples/basic/client/routes/auth.tsx +198 -0
- package/examples/basic/client/routes/cookies.tsx +199 -0
- package/examples/basic/client/routes/features/index.tsx +34 -10
- package/examples/basic/client/routes/hello.tsx +43 -0
- package/examples/basic/client/routes/pq.tsx +135 -0
- package/examples/basic/server/AuthTestHandler.ts +15 -0
- package/examples/basic/server/AuthVerifyHandler.ts +23 -0
- package/examples/basic/server/CacheHandler.ts +25 -0
- package/examples/basic/server/DecoCache.ts +18 -0
- package/examples/basic/server/FastTrapHandler.ts +8 -0
- package/examples/basic/server/SpinHandler.ts +18 -0
- package/examples/basic/server/SsrGreetingRender.ts +27 -0
- package/examples/basic/server/authexample-main.ts +8 -0
- package/examples/basic/server/authtest-main.ts +8 -0
- package/examples/basic/server/authverify-main.ts +8 -0
- package/examples/basic/server/cache-main.ts +8 -0
- package/examples/basic/server/core/AppHandler.ts +243 -0
- package/examples/basic/server/deco-main.ts +18 -0
- package/examples/basic/server/main.ts +2 -0
- package/examples/basic/server/routes/Auth.ts +184 -0
- package/examples/basic/server/routes/PqDemo.ts +109 -0
- package/examples/basic/server/routes/Session.ts +73 -0
- package/examples/basic/server/spin-main.ts +13 -0
- package/examples/basic/server/ssr/greeting.slots.ts +19 -0
- package/examples/basic/server/ssr-main.ts +18 -0
- package/examples/basic/server/toil-server-env.d.ts +94 -0
- package/examples/basic/server/trap-main.ts +8 -0
- package/package.json +5 -3
- package/server/globals/auth.ts +281 -0
- package/server/runtime/README.md +61 -0
- package/server/runtime/env/Server.ts +12 -0
- package/server/runtime/exports/index.ts +17 -0
- package/server/runtime/exports/render.ts +51 -0
- package/server/runtime/http/base64.ts +104 -0
- package/server/runtime/http/cookie.ts +416 -0
- package/server/runtime/http/cookies.ts +197 -0
- package/server/runtime/http/date.ts +72 -0
- package/server/runtime/http/percent.ts +76 -0
- package/server/runtime/http/securecookies.ts +224 -0
- package/server/runtime/index.ts +17 -0
- package/server/runtime/request.ts +24 -0
- package/server/runtime/response.ts +29 -0
- package/server/runtime/ssr/Ssr.ts +43 -0
- package/server/runtime/ssr/encode.ts +110 -0
- package/server/runtime/ssr/escape.ts +83 -0
- package/server/runtime/ssr/slots.ts +144 -0
- package/server/runtime/time.ts +29 -0
- package/src/cli/create.ts +105 -0
- package/src/client/auth.ts +322 -0
- package/src/client/index.ts +5 -1
- package/src/client/routing/loader.ts +56 -0
- package/src/client/routing/mount.tsx +37 -1
- package/src/client/ssr/markers.tsx +140 -0
- package/src/compiler/docs.ts +88 -1
- package/src/compiler/generate.ts +2 -2
- package/src/compiler/index.ts +5 -0
- package/src/compiler/ssr-codegen.ts +85 -0
- package/src/compiler/template-build.ts +275 -0
- package/src/compiler/template.ts +265 -0
- package/src/devserver/crypto.ts +23 -0
- package/src/devserver/host.ts +4 -0
- package/src/devserver/module.ts +39 -1
- package/test/assembly/cookie.spec.ts +302 -0
- package/test/assembly/example.spec.ts +5 -1
- package/test/assembly/ssr.spec.ts +94 -0
- package/test/devserver.test.ts +42 -0
- package/test/ssr-render.test.ts +128 -0
- package/test/ssr-template.test.tsx +348 -0
|
@@ -39,9 +39,252 @@ export class AppHandler extends ToilHandler {
|
|
|
39
39
|
return Response.text(crypto.randomUUID() + '\n');
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Cookies. `Cookie`, `Cookies`, `SecureCookies`, and `SameSite` are ambient
|
|
43
|
+
// globals (no import), exactly like `crypto`. The demo lives in its own
|
|
44
|
+
// method; the client page is `client/routes/cookies.tsx`.
|
|
45
|
+
const cookie = this.cookieDemo(req);
|
|
46
|
+
if (cookie != null) return cookie;
|
|
47
|
+
|
|
42
48
|
// Unhandled (not a plain notFound): tells the host this server has no
|
|
43
49
|
// answer for the path, so it may serve it itself. Under `toiljs dev`
|
|
44
50
|
// that falls through to Vite (client routes, assets).
|
|
45
51
|
return Response.unhandled();
|
|
46
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `/api/cookies/*` demo. Each endpoint returns JSON so the client page can
|
|
56
|
+
* render the actual cookie output. Returns `null` for a non-cookie path.
|
|
57
|
+
*/
|
|
58
|
+
private cookieDemo(req: Request): Response | null {
|
|
59
|
+
// GALLERY: the serialized `Set-Cookie` output of every capability, no
|
|
60
|
+
// round-trip needed. This is the "everything you can do" reference.
|
|
61
|
+
if (req.path == '/api/cookies/gallery') {
|
|
62
|
+
const labels = new Array<string>();
|
|
63
|
+
const cookies = new Array<string>();
|
|
64
|
+
|
|
65
|
+
labels.push('basic');
|
|
66
|
+
cookies.push(Cookie.create('id', 'abc123').serialize());
|
|
67
|
+
labels.push('percent-encoded (default)');
|
|
68
|
+
cookies.push(Cookie.create('msg', 'hello world & more!').serialize());
|
|
69
|
+
labels.push('base64url-encoded');
|
|
70
|
+
cookies.push(Cookie.create('data', 'hello').withEncoding(CookieEncoding.Base64Url).serialize());
|
|
71
|
+
labels.push('raw (no encoding)');
|
|
72
|
+
cookies.push(Cookie.create('tok', 'AAAA.BBBB').withEncoding(CookieEncoding.Raw).serialize());
|
|
73
|
+
labels.push('Max-Age');
|
|
74
|
+
cookies.push(Cookie.create('a', 'b').maxAge(3600).serialize());
|
|
75
|
+
labels.push('Expires (from epoch seconds)');
|
|
76
|
+
cookies.push(Cookie.create('a', 'b').expires(1700000000).serialize());
|
|
77
|
+
labels.push('Domain + Path');
|
|
78
|
+
cookies.push(Cookie.create('a', 'b').domain('example.com').path('/app').serialize());
|
|
79
|
+
labels.push('Secure + HttpOnly');
|
|
80
|
+
cookies.push(Cookie.create('a', 'b').secure().httpOnly().serialize());
|
|
81
|
+
labels.push('SameSite=Strict');
|
|
82
|
+
cookies.push(Cookie.create('a', 'b').sameSite(SameSite.Strict).serialize());
|
|
83
|
+
labels.push('SameSite=None (implies Secure)');
|
|
84
|
+
cookies.push(Cookie.create('a', 'b').sameSite(SameSite.None).serialize());
|
|
85
|
+
labels.push('Partitioned / CHIPS (implies Secure)');
|
|
86
|
+
cookies.push(Cookie.create('a', 'b').partitioned().serialize());
|
|
87
|
+
labels.push('Priority');
|
|
88
|
+
cookies.push(Cookie.create('a', 'b').priority('High').serialize());
|
|
89
|
+
labels.push('__Host- prefix (Secure + Path=/ + no Domain)');
|
|
90
|
+
cookies.push(Cookie.create('sid', 'x').asHostPrefixed().serialize());
|
|
91
|
+
labels.push('__Secure- prefix');
|
|
92
|
+
cookies.push(Cookie.create('sid', 'x').asSecurePrefixed().serialize());
|
|
93
|
+
labels.push('Max-Age clamped to the 400-day cap');
|
|
94
|
+
cookies.push(Cookie.create('a', 'b').maxAge(99999999).serialize());
|
|
95
|
+
labels.push('everything at once');
|
|
96
|
+
cookies.push(
|
|
97
|
+
Cookie.create('full', 'v')
|
|
98
|
+
.domain('example.com')
|
|
99
|
+
.path('/')
|
|
100
|
+
.maxAge(86400)
|
|
101
|
+
.secure()
|
|
102
|
+
.httpOnly()
|
|
103
|
+
.sameSite(SameSite.Lax)
|
|
104
|
+
.partitioned()
|
|
105
|
+
.priority('Medium')
|
|
106
|
+
.extension('CustomFlag')
|
|
107
|
+
.serialize(),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
let json = '{';
|
|
111
|
+
for (let i = 0; i < labels.length; i++) {
|
|
112
|
+
if (i > 0) json += ',';
|
|
113
|
+
json += '"' + this.esc(labels[i]) + '":"' + this.esc(cookies[i]) + '"';
|
|
114
|
+
}
|
|
115
|
+
json += '}';
|
|
116
|
+
return Response.json(json);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// SET: store three real cookies on the browser. A plain visit counter
|
|
120
|
+
// (readable by JS), an HMAC-signed session, and an AES-256-GCM-encrypted
|
|
121
|
+
// secret (both HttpOnly, so invisible to JS but readable by the server).
|
|
122
|
+
if (req.path == '/api/cookies/set') {
|
|
123
|
+
const prev = req.cookie('visits');
|
|
124
|
+
let next: string;
|
|
125
|
+
if (prev == null) next = '1';
|
|
126
|
+
else next = (this.toI32(prev) + 1).toString();
|
|
127
|
+
|
|
128
|
+
const visits = Cookie.create('visits', next).path('/').sameSite(SameSite.Lax).maxAge(86400);
|
|
129
|
+
const session = SecureCookies.signed(this.demoKey()).seal(
|
|
130
|
+
Cookie.create('session', 'user-42').httpOnly().sameSite(SameSite.Strict).asHostPrefixed(),
|
|
131
|
+
);
|
|
132
|
+
const secret = SecureCookies.encrypted(this.demoKey()).seal(
|
|
133
|
+
Cookie.create('secret', 'top-secret-value').httpOnly().path('/'),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const json =
|
|
137
|
+
'{"visits":' +
|
|
138
|
+
next +
|
|
139
|
+
',"emitted":["' +
|
|
140
|
+
this.esc(visits.serialize()) +
|
|
141
|
+
'","' +
|
|
142
|
+
this.esc(session.serialize()) +
|
|
143
|
+
'","' +
|
|
144
|
+
this.esc(secret.serialize()) +
|
|
145
|
+
'"]}';
|
|
146
|
+
return Response.json(json).setCookie(visits).setCookie(session).setCookie(secret);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// INSPECT: what the server sees. Parses the `Cookie` header, then verifies
|
|
150
|
+
// the signed session (HMAC) and decrypts the secret (AES-GCM) server-side.
|
|
151
|
+
if (req.path == '/api/cookies/inspect') {
|
|
152
|
+
const raw = req.header('cookie');
|
|
153
|
+
const jar = req.cookies();
|
|
154
|
+
const names = jar.names();
|
|
155
|
+
|
|
156
|
+
let parsed = '{';
|
|
157
|
+
for (let i = 0; i < names.length; i++) {
|
|
158
|
+
if (i > 0) parsed += ',';
|
|
159
|
+
const val = jar.get(names[i]);
|
|
160
|
+
parsed += '"' + this.esc(names[i]) + '":"' + this.esc(val == null ? '' : val) + '"';
|
|
161
|
+
}
|
|
162
|
+
parsed += '}';
|
|
163
|
+
|
|
164
|
+
const session = SecureCookies.signed(this.demoKey()).open(jar, '__Host-session');
|
|
165
|
+
const secret = SecureCookies.encrypted(this.demoKey()).open(jar, 'secret');
|
|
166
|
+
|
|
167
|
+
const json =
|
|
168
|
+
'{"raw":"' +
|
|
169
|
+
this.esc(raw == null ? '' : raw) +
|
|
170
|
+
'","count":' +
|
|
171
|
+
names.length.toString() +
|
|
172
|
+
',"cookies":' +
|
|
173
|
+
parsed +
|
|
174
|
+
',"session":' +
|
|
175
|
+
(session == null ? 'null' : '"' + this.esc(session) + '"') +
|
|
176
|
+
',"secret":' +
|
|
177
|
+
(secret == null ? 'null' : '"' + this.esc(secret) + '"') +
|
|
178
|
+
'}';
|
|
179
|
+
return Response.json(json);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// CLEAR: expire the demo cookies (Max-Age=0 + epoch Expires).
|
|
183
|
+
if (req.path == '/api/cookies/clear') {
|
|
184
|
+
const json =
|
|
185
|
+
'{"cleared":["' +
|
|
186
|
+
this.esc(this.clearString('visits')) +
|
|
187
|
+
'","' +
|
|
188
|
+
this.esc(this.clearString('__Host-session')) +
|
|
189
|
+
'","' +
|
|
190
|
+
this.esc(this.clearString('secret')) +
|
|
191
|
+
'"]}';
|
|
192
|
+
return Response.json(json)
|
|
193
|
+
.clearCookie('visits')
|
|
194
|
+
.clearCookie('__Host-session')
|
|
195
|
+
.clearCookie('secret');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// SEAL: sign and encrypt a value (from `?v=`), then recover both and show
|
|
199
|
+
// that a tampered signature fails to verify. Pure backend crypto, no headers.
|
|
200
|
+
if (req.path.indexOf('/api/cookies/seal') == 0) {
|
|
201
|
+
const value = this.queryValue(req.path, 'v', 'hello toiljs');
|
|
202
|
+
const signer = SecureCookies.signed(this.demoKey());
|
|
203
|
+
const box = SecureCookies.encrypted(this.demoKey());
|
|
204
|
+
|
|
205
|
+
const signed = signer.sign('demo', value);
|
|
206
|
+
const encrypted = box.encrypt('demo', value);
|
|
207
|
+
const unsigned = signer.unsign('demo', signed);
|
|
208
|
+
const decrypted = box.decrypt('demo', encrypted);
|
|
209
|
+
const tampered = signer.unsign('demo', this.flip(signed));
|
|
210
|
+
|
|
211
|
+
const json =
|
|
212
|
+
'{"value":"' +
|
|
213
|
+
this.esc(value) +
|
|
214
|
+
'","signed":"' +
|
|
215
|
+
this.esc(signed) +
|
|
216
|
+
'","unsigned":' +
|
|
217
|
+
(unsigned == null ? 'null' : '"' + this.esc(unsigned) + '"') +
|
|
218
|
+
',"encrypted":"' +
|
|
219
|
+
this.esc(encrypted) +
|
|
220
|
+
'","decrypted":' +
|
|
221
|
+
(decrypted == null ? 'null' : '"' + this.esc(decrypted) + '"') +
|
|
222
|
+
',"tamperVerifies":' +
|
|
223
|
+
(tampered == null ? 'false' : 'true') +
|
|
224
|
+
'}';
|
|
225
|
+
return Response.json(json);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
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.
|
|
233
|
+
private demoKey(): Uint8Array {
|
|
234
|
+
return Uint8Array.wrap(String.UTF8.encode('0123456789abcdef0123456789abcdef'));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The `Set-Cookie` string `clearCookie(name)` emits, for display. */
|
|
238
|
+
private clearString(name: string): string {
|
|
239
|
+
return new Cookie(name, '').path('/').maxAge(0).expires(0).serialize();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Flip the first character (tamper a sealed value while keeping it base64url). */
|
|
243
|
+
private flip(s: string): string {
|
|
244
|
+
if (s.length == 0) return 'A';
|
|
245
|
+
const c = s.charCodeAt(0);
|
|
246
|
+
return String.fromCharCode(c == 65 ? 66 : 65) + s.substring(1);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Read `?key=` (or `&key=`) from `path`, percent-decoded, or `fallback`. */
|
|
250
|
+
private queryValue(path: string, key: string, fallback: string): string {
|
|
251
|
+
const q = path.indexOf('?');
|
|
252
|
+
if (q < 0) return fallback;
|
|
253
|
+
const pairs = path.substring(q + 1).split('&');
|
|
254
|
+
const prefix = key + '=';
|
|
255
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
256
|
+
if (pairs[i].indexOf(prefix) == 0) {
|
|
257
|
+
return Cookies.decodeValue(pairs[i].substring(prefix.length));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return fallback;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Parse a non-negative base-10 integer prefix of `s`. */
|
|
264
|
+
private toI32(s: string): i32 {
|
|
265
|
+
let r = 0;
|
|
266
|
+
for (let i = 0; i < s.length; i++) {
|
|
267
|
+
const c = s.charCodeAt(i);
|
|
268
|
+
if (c < 48 || c > 57) break;
|
|
269
|
+
r = r * 10 + (c - 48);
|
|
270
|
+
}
|
|
271
|
+
return r;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** JSON string escaping for the demo's hand-built JSON (incl. all controls). */
|
|
275
|
+
private esc(s: string): string {
|
|
276
|
+
const hex = '0123456789abcdef';
|
|
277
|
+
let out = '';
|
|
278
|
+
for (let i = 0; i < s.length; i++) {
|
|
279
|
+
const c = s.charCodeAt(i);
|
|
280
|
+
if (c == 34) out += '\\"';
|
|
281
|
+
else if (c == 92) out += '\\\\';
|
|
282
|
+
else if (c == 10) out += '\\n';
|
|
283
|
+
else if (c == 13) out += '\\r';
|
|
284
|
+
else if (c == 9) out += '\\t';
|
|
285
|
+
else if (c < 0x20) out += '\\u00' + hex.charAt((c >> 4) & 0xf) + hex.charAt(c & 0xf);
|
|
286
|
+
else out += String.fromCharCode(c);
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
47
290
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Server } from 'toiljs/server/runtime';
|
|
2
|
+
import { revertOnError } from 'toiljs/server/runtime/abort/abort';
|
|
3
|
+
import { Request, Response, Rest, ToilHandler } from 'toiljs/server/runtime';
|
|
4
|
+
import './DecoCache';
|
|
5
|
+
|
|
6
|
+
class DecoHandler extends ToilHandler {
|
|
7
|
+
public handle(req: Request): Response {
|
|
8
|
+
const hit = Rest.dispatch(req);
|
|
9
|
+
if (hit != null) return hit;
|
|
10
|
+
return Response.notFound();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
Server.handler = () => { return new DecoHandler(); };
|
|
15
|
+
export * from 'toiljs/server/runtime/exports';
|
|
16
|
+
export function abort(message: string, fileName: string, line: u32, column: u32): void {
|
|
17
|
+
revertOnError(message, fileName, line, column);
|
|
18
|
+
}
|
|
@@ -8,6 +8,8 @@ import { AppHandler } from './core/AppHandler';
|
|
|
8
8
|
// run (which only sees the toilconfig entries) building the exact same server.
|
|
9
9
|
import './routes/Players';
|
|
10
10
|
import './routes/Leaderboard';
|
|
11
|
+
import './routes/Session';
|
|
12
|
+
import './routes/PqDemo';
|
|
11
13
|
import './services/Stats';
|
|
12
14
|
import './services/remotes';
|
|
13
15
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { Method, Response, RouteContext } from 'toiljs/server/runtime';
|
|
2
|
+
import { DataReader, DataWriter } from 'data';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Post-quantum auth, illustrative. Shows how a tenant wires the no-import
|
|
6
|
+
* `AuthService` global into a challenge-response login. ML-DSA-44 keypairs are
|
|
7
|
+
* derived client-side from the password (Argon2id); the server only ever stores
|
|
8
|
+
* and verifies PUBLIC material.
|
|
9
|
+
*
|
|
10
|
+
* STORAGE IS THE APP'S, AND THIS EXAMPLE DOES NOT PROVIDE IT. A tenant's wasm
|
|
11
|
+
* memory is wiped after every request, so the account record and the login
|
|
12
|
+
* challenges CANNOT live in this module across the two round trips. A real
|
|
13
|
+
* deployment must back `Accounts` and `Challenges` with an external store, and
|
|
14
|
+
* the challenge "consume" MUST be a single atomic fetch-and-delete shared by
|
|
15
|
+
* all instances (Redis `GETDEL`, or SQL `DELETE ... RETURNING`) -- never a
|
|
16
|
+
* read-then-delete, or a sniffed signature replays across a race. The stubs
|
|
17
|
+
* below throw to make that explicit; swap them for your store + a host KV/db
|
|
18
|
+
* binding. The crypto and encoding (`AuthService`) are production-ready; the
|
|
19
|
+
* orchestration here is a template.
|
|
20
|
+
*
|
|
21
|
+
* Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
|
|
22
|
+
* The client half lives in `toiljs/client` (`Auth.register` / `Auth.login`).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const AUD = 'toil-demo'; // this service's audience id (server config; never client-echoed)
|
|
26
|
+
const MIN_MEM_KIB = 256 * 1024; // 256 MiB floor (KDF-params-as-credential)
|
|
27
|
+
const MIN_ITERATIONS = 3;
|
|
28
|
+
|
|
29
|
+
class AccountRecord {
|
|
30
|
+
username: string = '';
|
|
31
|
+
salt: Uint8Array = new Uint8Array(0);
|
|
32
|
+
publicKey: Uint8Array = new Uint8Array(0);
|
|
33
|
+
memKiB: u32 = 0;
|
|
34
|
+
iterations: u32 = 0;
|
|
35
|
+
parallelism: u32 = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class ChallengeRecord {
|
|
39
|
+
cid: Uint8Array = new Uint8Array(0);
|
|
40
|
+
username: string = '';
|
|
41
|
+
nonce: Uint8Array = new Uint8Array(0);
|
|
42
|
+
iat: u64 = 0;
|
|
43
|
+
exp: u64 = 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- the storage the app MUST provide (external; these throw on purpose) -----
|
|
47
|
+
namespace Accounts {
|
|
48
|
+
export function get(_username: string): AccountRecord | null {
|
|
49
|
+
throw new Error('wire Accounts to your store');
|
|
50
|
+
}
|
|
51
|
+
export function exists(_username: string): bool {
|
|
52
|
+
throw new Error('wire Accounts to your store');
|
|
53
|
+
}
|
|
54
|
+
export function put(_a: AccountRecord): void {
|
|
55
|
+
throw new Error('wire Accounts to your store');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
namespace Challenges {
|
|
59
|
+
export function put(_c: ChallengeRecord): void {
|
|
60
|
+
throw new Error('wire Challenges to your store');
|
|
61
|
+
}
|
|
62
|
+
/** Atomic fetch-and-delete by cid (Redis GETDEL / SQL DELETE RETURNING). */
|
|
63
|
+
export function consume(_cid: Uint8Array): ChallengeRecord | null {
|
|
64
|
+
throw new Error('wire Challenges to an ATOMIC store');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function randomBytes(n: i32): Uint8Array {
|
|
69
|
+
const b = new Uint8Array(n);
|
|
70
|
+
crypto.getRandomValues(b);
|
|
71
|
+
return b;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fail(): Response {
|
|
75
|
+
// One generic error on every failure path (anti-enumeration, anti-oracle).
|
|
76
|
+
return Response.text('auth: request failed\n', 401);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@rest('auth')
|
|
80
|
+
class Auth {
|
|
81
|
+
/** POST /auth/register/start body: str(username)
|
|
82
|
+
* resp: u8(status=0) + u32(mem) u32(iters) u32(par) bytes(salt) */
|
|
83
|
+
@post('/register/start')
|
|
84
|
+
public registerStart(ctx: RouteContext): Response {
|
|
85
|
+
const username = new DataReader(ctx.request.body).readString();
|
|
86
|
+
if (Accounts.exists(username)) {
|
|
87
|
+
return new Response(200, new DataWriter().writeU8(1).toBytes().slice(0)); // taken
|
|
88
|
+
}
|
|
89
|
+
const salt = randomBytes(16);
|
|
90
|
+
const w = new DataWriter();
|
|
91
|
+
w.writeU8(0);
|
|
92
|
+
w.writeU32(<u32>MIN_MEM_KIB);
|
|
93
|
+
w.writeU32(<u32>MIN_ITERATIONS);
|
|
94
|
+
w.writeU32(1);
|
|
95
|
+
w.writeBytes(salt);
|
|
96
|
+
// NOTE: the salt must be persisted with the pending registration so
|
|
97
|
+
// registerFinish stores the same one; omitted here (no store).
|
|
98
|
+
return Response.bytes(w.toBytes());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** POST /auth/register/finish body: str(username) bytes(pk) resp: u8(status) */
|
|
102
|
+
@post('/register/finish')
|
|
103
|
+
public registerFinish(ctx: RouteContext): Response {
|
|
104
|
+
const r = new DataReader(ctx.request.body);
|
|
105
|
+
const username = r.readString();
|
|
106
|
+
const pk = r.readBytes();
|
|
107
|
+
if (Accounts.exists(username) || pk.length != 1312) return fail(); // ML-DSA-44 pk
|
|
108
|
+
const a = new AccountRecord();
|
|
109
|
+
a.username = username;
|
|
110
|
+
a.publicKey = pk;
|
|
111
|
+
a.memKiB = <u32>MIN_MEM_KIB;
|
|
112
|
+
a.iterations = <u32>MIN_ITERATIONS;
|
|
113
|
+
a.parallelism = 1;
|
|
114
|
+
// a.salt = <the salt issued in registerStart>;
|
|
115
|
+
Accounts.put(a);
|
|
116
|
+
return Response.bytes(new DataWriter().writeU8(0).toBytes());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** POST /auth/login/start body: str(username)
|
|
120
|
+
* resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt) bytes(nonce) u64(iat) u64(exp) */
|
|
121
|
+
@post('/login/start')
|
|
122
|
+
public loginStart(ctx: RouteContext): Response {
|
|
123
|
+
const username = new DataReader(ctx.request.body).readString();
|
|
124
|
+
const acct = Accounts.get(username);
|
|
125
|
+
|
|
126
|
+
const cid = randomBytes(16);
|
|
127
|
+
const nonce = randomBytes(32);
|
|
128
|
+
const iat = <u64>(Date.now() / 1000);
|
|
129
|
+
const exp = iat + 120;
|
|
130
|
+
|
|
131
|
+
// Anti-enumeration: unknown user still gets a fully-formed challenge with
|
|
132
|
+
// a throwaway salt; the eventual verify just fails.
|
|
133
|
+
const salt = acct != null ? acct.salt : randomBytes(16);
|
|
134
|
+
const mem = acct != null ? acct.memKiB : <u32>MIN_MEM_KIB;
|
|
135
|
+
const iters = acct != null ? acct.iterations : <u32>MIN_ITERATIONS;
|
|
136
|
+
const par = acct != null ? acct.parallelism : 1;
|
|
137
|
+
|
|
138
|
+
if (acct != null) {
|
|
139
|
+
const c = new ChallengeRecord();
|
|
140
|
+
c.cid = cid;
|
|
141
|
+
c.username = username;
|
|
142
|
+
c.nonce = nonce;
|
|
143
|
+
c.iat = iat;
|
|
144
|
+
c.exp = exp;
|
|
145
|
+
Challenges.put(c);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const w = new DataWriter();
|
|
149
|
+
w.writeBytes(cid);
|
|
150
|
+
w.writeString(AUD);
|
|
151
|
+
w.writeU32(mem);
|
|
152
|
+
w.writeU32(iters);
|
|
153
|
+
w.writeU32(par);
|
|
154
|
+
w.writeBytes(salt);
|
|
155
|
+
w.writeBytes(nonce);
|
|
156
|
+
w.writeU64(iat);
|
|
157
|
+
w.writeU64(exp);
|
|
158
|
+
return Response.bytes(w.toBytes());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** POST /auth/login/finish body: bytes(cid) bytes(sig) resp: u8(status) [+ bytes(session)] */
|
|
162
|
+
@post('/login/finish')
|
|
163
|
+
public loginFinish(ctx: RouteContext): Response {
|
|
164
|
+
const r = new DataReader(ctx.request.body);
|
|
165
|
+
const cid = r.readBytes();
|
|
166
|
+
const sig = r.readBytes();
|
|
167
|
+
|
|
168
|
+
// 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
|
|
169
|
+
const ch = Challenges.consume(cid);
|
|
170
|
+
if (ch == null) return fail();
|
|
171
|
+
const now = <u64>(Date.now() / 1000);
|
|
172
|
+
if (now >= ch.exp) return fail();
|
|
173
|
+
|
|
174
|
+
// 2. Rebuild the message from OUR stored values (never client-echoed),
|
|
175
|
+
// load the account's public key, verify under the login context.
|
|
176
|
+
const acct = Accounts.get(ch.username);
|
|
177
|
+
if (acct == null) return fail();
|
|
178
|
+
const message = AuthService.buildLoginMessage(ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp);
|
|
179
|
+
if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
|
|
180
|
+
|
|
181
|
+
// 3. Success: mint a session (cookie / token). App-specific.
|
|
182
|
+
return Response.bytes(new DataWriter().writeU8(0).toBytes());
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Response, RouteContext, SecureCookies, base64UrlEncode, base64UrlDecode } from 'toiljs/server/runtime';
|
|
2
|
+
import { DataReader, DataWriter } from 'data';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Post-quantum identity demo (server half), challenge-response.
|
|
6
|
+
*
|
|
7
|
+
* 1. GET /pq/challenge -> the edge mints a fresh nonce + cid + iat/exp and
|
|
8
|
+
* returns them PLUS an HMAC-signed `token` over those values. The token is
|
|
9
|
+
* the server-issued challenge: signed with a server-only key, it proves
|
|
10
|
+
* "the edge issued exactly this" WITHOUT any cross-request storage (the
|
|
11
|
+
* guest's memory is wiped every request).
|
|
12
|
+
* 2. POST /pq/verify -> the client signs the login message built from the
|
|
13
|
+
* SERVER's nonce/cid/iat/exp (ML-DSA-44, derived from the password) and
|
|
14
|
+
* returns {sub, token, publicKey, signature}. The edge re-opens the token
|
|
15
|
+
* (rejecting a forged or expired one), rebuilds the message from the values
|
|
16
|
+
* INSIDE the token (never client-echoed), and verifies the signature via
|
|
17
|
+
* `crypto.mldsa_verify` (`AuthService.verifyLogin`).
|
|
18
|
+
*
|
|
19
|
+
* The nonce is server-chosen and tamper-proof, and the challenge is time-bound,
|
|
20
|
+
* so a client cannot pre-sign or substitute its own nonce. What this stateless
|
|
21
|
+
* form does NOT have is single-use: within the TTL a captured {token, signature}
|
|
22
|
+
* could be replayed, because that needs an atomic consume against a store (Redis
|
|
23
|
+
* GETDEL / SQL DELETE RETURNING). The production login in server/routes/Auth.ts
|
|
24
|
+
* does exactly that; see docs/auth.md. Pairs with client/routes/pq.tsx.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const AUD = 'pq-demo'; // this demo's audience id (server config; never client-echoed)
|
|
28
|
+
const CHALLENGE_TTL_SECS: u64 = 120;
|
|
29
|
+
|
|
30
|
+
/** Server-only key for signing challenge tokens (demo constant; a real
|
|
31
|
+
* deployment uses a per-deployment secret, like the session secret). */
|
|
32
|
+
function challengeKey(): Uint8Array {
|
|
33
|
+
return crypto.sha256Text('toil-pq-demo-challenge-key-v1');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function randomBytes(n: i32): Uint8Array {
|
|
37
|
+
const b = new Uint8Array(n);
|
|
38
|
+
crypto.getRandomValues(b);
|
|
39
|
+
return b;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@rest('pq')
|
|
43
|
+
class PqDemo {
|
|
44
|
+
/** GET /pq/challenge
|
|
45
|
+
* resp: str(aud) bytes(cid) bytes(nonce) u64(iat) u64(exp) str(token) */
|
|
46
|
+
@get('/challenge')
|
|
47
|
+
public challenge(_ctx: RouteContext): Response {
|
|
48
|
+
const cid = randomBytes(16);
|
|
49
|
+
const nonce = randomBytes(32);
|
|
50
|
+
const iat = Time.nowSeconds();
|
|
51
|
+
const exp = iat + CHALLENGE_TTL_SECS;
|
|
52
|
+
|
|
53
|
+
// Sign (iat, exp, cid, nonce) so /verify can confirm the edge issued
|
|
54
|
+
// this exact challenge with no stored state.
|
|
55
|
+
const blob = new DataWriter()
|
|
56
|
+
.writeU64(iat)
|
|
57
|
+
.writeU64(exp)
|
|
58
|
+
.writeBytes(cid)
|
|
59
|
+
.writeBytes(nonce)
|
|
60
|
+
.toBytes();
|
|
61
|
+
const token = SecureCookies.signed(challengeKey()).sign('pqchal', base64UrlEncode(blob));
|
|
62
|
+
|
|
63
|
+
const w = new DataWriter();
|
|
64
|
+
w.writeString(AUD);
|
|
65
|
+
w.writeBytes(cid);
|
|
66
|
+
w.writeBytes(nonce);
|
|
67
|
+
w.writeU64(iat);
|
|
68
|
+
w.writeU64(exp);
|
|
69
|
+
w.writeString(token);
|
|
70
|
+
return Response.bytes(w.toBytes());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** POST /pq/verify
|
|
74
|
+
* body: str(sub) str(token) bytes(publicKey 1312) bytes(signature 2420)
|
|
75
|
+
* resp: text VALID / INVALID */
|
|
76
|
+
@post('/verify')
|
|
77
|
+
public verify(ctx: RouteContext): Response {
|
|
78
|
+
const r = new DataReader(ctx.request.body);
|
|
79
|
+
const sub = r.readString();
|
|
80
|
+
const token = r.readString();
|
|
81
|
+
const pk = r.readBytes();
|
|
82
|
+
const sig = r.readBytes();
|
|
83
|
+
if (!r.ok) return Response.text('malformed envelope\n', 400);
|
|
84
|
+
|
|
85
|
+
// 1. Re-open the challenge token: must be server-issued + untampered.
|
|
86
|
+
const blobB64 = SecureCookies.signed(challengeKey()).unsign('pqchal', token);
|
|
87
|
+
if (blobB64 == null) return Response.text('INVALID: forged or unsigned challenge\n', 401);
|
|
88
|
+
const blob = base64UrlDecode(blobB64);
|
|
89
|
+
if (blob == null) return Response.text('INVALID: malformed challenge\n', 401);
|
|
90
|
+
const br = new DataReader(blob);
|
|
91
|
+
const iat = br.readU64();
|
|
92
|
+
const exp = br.readU64();
|
|
93
|
+
const cid = br.readBytes();
|
|
94
|
+
const nonce = br.readBytes();
|
|
95
|
+
if (!br.ok) return Response.text('INVALID: malformed challenge\n', 401);
|
|
96
|
+
if (Time.nowSeconds() >= exp) return Response.text('INVALID: challenge expired\n', 401);
|
|
97
|
+
|
|
98
|
+
// 2. Rebuild the message from the SERVER's values (inside the token,
|
|
99
|
+
// never client-echoed) and verify the ML-DSA-44 signature.
|
|
100
|
+
const message = AuthService.buildLoginMessage(sub, AUD, cid, nonce, iat, exp);
|
|
101
|
+
const ok = AuthService.verifyLogin(pk, message, sig);
|
|
102
|
+
return Response.text(
|
|
103
|
+
ok
|
|
104
|
+
? 'VALID: server-issued challenge signed and verified (ML-DSA-44, FIPS 204)\n'
|
|
105
|
+
: 'INVALID: signature did not verify\n',
|
|
106
|
+
ok ? 200 : 401,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Response, RouteContext } from 'toiljs/server/runtime';
|
|
2
|
+
import { DataReader, DataWriter } from 'data';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Session demo: the `@user` / `@auth` / typed `AuthService.getUser()` surface.
|
|
6
|
+
*
|
|
7
|
+
* `@user` declares the authenticated user's shape; it becomes a binary codec
|
|
8
|
+
* (like `@data`) AND registers the type of `AuthService.getUser()` everywhere,
|
|
9
|
+
* server and generated client, with NO type argument.
|
|
10
|
+
*
|
|
11
|
+
* `@auth` on a route (or a whole `@rest` class) makes the generated dispatcher
|
|
12
|
+
* verify a valid signed session BEFORE the handler runs (401 otherwise).
|
|
13
|
+
*
|
|
14
|
+
* The session is an HMAC-signed `__Host-` cookie minted by `AuthService.mintSession`.
|
|
15
|
+
* In a real app you mint it in `Auth.loginFinish` AFTER `verifyLogin` succeeds;
|
|
16
|
+
* this `/session/dev-login` mints one for a caller-named demo user so the flow is
|
|
17
|
+
* runnable without the external account store the login example stubs out.
|
|
18
|
+
*
|
|
19
|
+
* The server secret defaults to a well-known DEV placeholder; a real deployment
|
|
20
|
+
* calls `AuthService.setSecret(...)` once at startup (see server/main.ts).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// @user: the authenticated-user shape. Exactly one per program.
|
|
24
|
+
@user
|
|
25
|
+
class Account {
|
|
26
|
+
username: string = '';
|
|
27
|
+
admin: bool = false;
|
|
28
|
+
score: u64 = 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@rest('session')
|
|
32
|
+
class Session {
|
|
33
|
+
/** POST /session/dev-login body: str(username) -> sets the session cookie.
|
|
34
|
+
* DEV ONLY: a real app mints in loginFinish after the signature verifies. */
|
|
35
|
+
@post('/dev-login')
|
|
36
|
+
public devLogin(ctx: RouteContext): Response {
|
|
37
|
+
const username = new DataReader(ctx.request.body).readString();
|
|
38
|
+
const u = new Account();
|
|
39
|
+
u.username = username;
|
|
40
|
+
u.admin = username == 'root';
|
|
41
|
+
u.score = 0;
|
|
42
|
+
|
|
43
|
+
const data = u.encode();
|
|
44
|
+
const resp = Response.text('ok\n', 200);
|
|
45
|
+
resp.setCookie(AuthService.mintSession(data, 3600)); // HttpOnly signed session
|
|
46
|
+
resp.setCookie(AuthService.userCookie(data, 3600)); // readable companion (client getUser)
|
|
47
|
+
return resp;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** GET /session/me (@auth: 401 without a valid session) -> the typed user.
|
|
51
|
+
* `AuthService.getUser()` is auto-typed to `Account` with no type argument. */
|
|
52
|
+
@auth
|
|
53
|
+
@get('/me')
|
|
54
|
+
public me(_ctx: RouteContext): Response {
|
|
55
|
+
const u = AuthService.getUser();
|
|
56
|
+
if (u == null) return Response.text('no session\n', 401);
|
|
57
|
+
const w = new DataWriter();
|
|
58
|
+
w.writeString(u.username);
|
|
59
|
+
w.writeBool(u.admin);
|
|
60
|
+
w.writeU64(u.score);
|
|
61
|
+
return Response.bytes(w.toBytes());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** POST /session/logout (@auth) -> clears the session cookie. */
|
|
65
|
+
@auth
|
|
66
|
+
@post('/logout')
|
|
67
|
+
public logout(_ctx: RouteContext): Response {
|
|
68
|
+
const resp = Response.text('bye\n', 200);
|
|
69
|
+
resp.setCookie(AuthService.clearSession());
|
|
70
|
+
resp.setCookie(AuthService.clearUserCookie());
|
|
71
|
+
return resp;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Server } from 'toiljs/server/runtime';
|
|
2
|
+
import { revertOnError } from 'toiljs/server/runtime/abort/abort';
|
|
3
|
+
import { SpinHandler } from './SpinHandler';
|
|
4
|
+
|
|
5
|
+
Server.handler = () => {
|
|
6
|
+
return new SpinHandler();
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export * from 'toiljs/server/runtime/exports';
|
|
10
|
+
|
|
11
|
+
export function abort(message: string, fileName: string, line: u32, column: u32): void {
|
|
12
|
+
revertOnError(message, fileName, line, column);
|
|
13
|
+
}
|