toiljs 0.0.54 → 0.0.56
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 +5 -0
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +9 -5
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.js +1 -1
- package/build/client/components/Image.d.ts +1 -1
- package/build/client/dev/devtools.js +3 -1
- package/build/client/index.d.ts +2 -2
- package/build/client/index.js +2 -2
- package/build/client/routing/Router.js +1 -1
- package/build/client/routing/mount.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/docs.js +1 -1
- package/build/compiler/seo.js +1 -3
- package/build/compiler/template-build.js +1 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/cache.js +0 -0
- package/build/devserver/crypto.js +45 -17
- package/build/devserver/database.d.ts +8 -0
- package/build/devserver/database.js +416 -0
- package/build/devserver/email/caps.js +0 -0
- package/build/devserver/email/config.js +7 -2
- package/build/devserver/email/validate.js +1 -4
- package/build/devserver/host.d.ts +2 -0
- package/build/devserver/host.js +3 -2
- package/build/devserver/index.d.ts +1 -1
- package/build/devserver/index.js +3 -2
- package/build/devserver/module.js +52 -7
- package/build/devserver/proxy.js +2 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/io/codec.d.ts +5 -5
- package/build/io/codec.js +193 -77
- package/examples/basic/client/components/HoneycombBackground.tsx +1 -1
- package/examples/basic/client/public/images/logo.svg +37 -34
- package/examples/basic/client/public/index.html +14 -14
- package/examples/basic/client/routes/auth.tsx +18 -10
- package/examples/basic/client/routes/cookies.tsx +15 -24
- package/examples/basic/client/routes/crypto.tsx +4 -5
- package/examples/basic/client/routes/features/template/template.tsx +1 -1
- package/examples/basic/client/routes/hello.tsx +1 -1
- package/examples/basic/client/routes/pq.tsx +14 -14
- package/examples/basic/client/routes/rest.tsx +50 -1
- package/examples/basic/client/styles/main.css +25 -22
- package/examples/basic/client/toil.tsx +1 -1
- package/examples/basic/server/README.md +8 -8
- package/examples/basic/server/core/AppHandler.ts +4 -7
- package/examples/basic/server/main.ts +1 -0
- package/examples/basic/server/models/GuestEntry.ts +12 -0
- package/examples/basic/server/models/GuestbookView.ts +10 -0
- package/examples/basic/server/models/NewMessage.ts +6 -0
- package/examples/basic/server/routes/Auth.ts +50 -106
- package/examples/basic/server/routes/EnvDemo.ts +9 -3
- package/examples/basic/server/routes/Guestbook.ts +62 -0
- package/package.json +2 -2
- package/server/globals/auth.ts +3 -3
- package/server/globals/twofactor.ts +2 -1
- package/server/runtime/http/securecookies.ts +3 -2
- package/src/backend/index.ts +4 -2
- package/src/cli/doctor.ts +10 -3
- package/src/cli/notify.ts +1 -6
- package/src/cli/ui.ts +3 -3
- package/src/cli/version-check.ts +5 -1
- package/src/client/auth.ts +33 -10
- package/src/client/components/Form.tsx +2 -2
- package/src/client/components/Image.tsx +1 -1
- package/src/client/components/Script.tsx +1 -1
- package/src/client/components/Slot.tsx +1 -1
- package/src/client/dev/devtools.tsx +121 -54
- package/src/client/dev/error-overlay.tsx +7 -1
- package/src/client/head/metadata.ts +1 -1
- package/src/client/index.ts +13 -2
- package/src/client/routing/Router.tsx +2 -2
- package/src/client/routing/error-boundary.tsx +1 -1
- package/src/client/routing/loader.ts +2 -2
- package/src/client/routing/mount.tsx +5 -6
- package/src/compiler/docs.ts +1 -1
- package/src/compiler/email-preview.ts +1 -1
- package/src/compiler/generate.ts +1 -1
- package/src/compiler/seo.ts +1 -3
- package/src/compiler/ssg.ts +10 -4
- package/src/compiler/template-build.ts +2 -7
- package/src/compiler/template.ts +1 -4
- package/src/compiler/vite.ts +1 -1
- package/src/devserver/cache.ts +0 -0
- package/src/devserver/crypto.ts +140 -51
- package/src/devserver/database.ts +600 -0
- package/src/devserver/dotenv.ts +10 -2
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +8 -2
- package/src/devserver/email/index.ts +3 -3
- package/src/devserver/email/validate.ts +1 -4
- package/src/devserver/envelope.ts +3 -3
- package/src/devserver/host.ts +22 -9
- package/src/devserver/index.ts +15 -6
- package/src/devserver/module.ts +59 -11
- package/src/devserver/proxy.ts +5 -7
- package/src/io/codec.ts +226 -83
- package/test/devserver-database.test.ts +364 -0
- package/test/devserver-pqauth.test.ts +5 -65
- package/test/example-guestbook.test.ts +78 -0
- package/test/pqauth-e2e.test.ts +6 -6
- package/build/devserver/kv.d.ts +0 -3
- package/build/devserver/kv.js +0 -53
- package/src/devserver/kv.ts +0 -93
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Response, RouteContext } from 'toiljs/server/runtime';
|
|
2
2
|
import { DataReader, DataWriter } from 'data';
|
|
3
|
+
import { Record } from 'toildb';
|
|
3
4
|
|
|
4
5
|
import { encodeSessionUser } from './Session';
|
|
5
6
|
|
|
@@ -15,11 +16,11 @@ import { encodeSessionUser } from './Session';
|
|
|
15
16
|
* auth). See `server/globals/auth.ts` (the `AuthService` global) and the client
|
|
16
17
|
* half in `toiljs/client` (`Auth.register` / `Auth.login`).
|
|
17
18
|
*
|
|
18
|
-
* STORAGE: backed by
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* STORAGE: backed by ToilDB (`@database AuthDb`). Accounts are a `record`
|
|
20
|
+
* collection keyed by username; login challenges are a `record` consumed exactly
|
|
21
|
+
* once with `getDelete` (atomic fetch-and-delete). The dev server emulates these
|
|
22
|
+
* `env.data.*` host imports in process (so register -> login spans requests under
|
|
23
|
+
* `toiljs dev`); the production edge backs the SAME API with ScyllaDB.
|
|
23
24
|
*
|
|
24
25
|
* Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
|
|
25
26
|
*/
|
|
@@ -36,19 +37,6 @@ const DEMO_PAR: u32 = 1;
|
|
|
36
37
|
const CHALLENGE_TTL_SECS: u64 = 120;
|
|
37
38
|
const SESSION_TTL_SECS: u64 = 3600;
|
|
38
39
|
|
|
39
|
-
function utf8(s: string): Uint8Array {
|
|
40
|
-
return Uint8Array.wrap(String.UTF8.encode(s));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function toHex(b: Uint8Array): string {
|
|
44
|
-
let s = '';
|
|
45
|
-
for (let i = 0; i < b.length; i++) {
|
|
46
|
-
const v = b[i];
|
|
47
|
-
s += (v < 16 ? '0' : '') + (<u32>v).toString(16);
|
|
48
|
-
}
|
|
49
|
-
return s;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
40
|
function randomBytes(n: i32): Uint8Array {
|
|
53
41
|
const b = new Uint8Array(n);
|
|
54
42
|
crypto.getRandomValues(b);
|
|
@@ -76,45 +64,28 @@ function deriveSalt(username: string): Uint8Array {
|
|
|
76
64
|
return crypto.sha256Text('toil-demo-salt-v1:' + username).slice(0, 16);
|
|
77
65
|
}
|
|
78
66
|
|
|
67
|
+
// ToilDB collections (the `kv.*` dev placeholder is gone). The key + value are
|
|
68
|
+
// `@data` types: the binary codec is generated, the host marshals it, and the
|
|
69
|
+
// challenge is consumed exactly once with `getDelete`.
|
|
79
70
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// @ts-ignore: decorator
|
|
87
|
-
@external('env', 'kv.getdel')
|
|
88
|
-
declare function __kvGetDel(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
|
|
89
|
-
|
|
90
|
-
const KV_CAP: i32 = 8192; // bounds account (~1.5 KiB) + challenge (~100 B) records
|
|
91
|
-
|
|
92
|
-
function kvPut(key: Uint8Array, val: Uint8Array): void {
|
|
93
|
-
__kvPut(key.dataStart, key.length, val.dataStart, val.length);
|
|
94
|
-
}
|
|
95
|
-
function kvGet(key: Uint8Array): Uint8Array | null {
|
|
96
|
-
const out = new Uint8Array(KV_CAP);
|
|
97
|
-
const n = __kvGet(key.dataStart, key.length, out.dataStart, KV_CAP);
|
|
98
|
-
if (n < 0) return null;
|
|
99
|
-
return out.slice(0, n);
|
|
100
|
-
}
|
|
101
|
-
/** Atomic fetch-and-delete: consumes a login challenge exactly once. */
|
|
102
|
-
function kvGetDel(key: Uint8Array): Uint8Array | null {
|
|
103
|
-
const out = new Uint8Array(KV_CAP);
|
|
104
|
-
const n = __kvGetDel(key.dataStart, key.length, out.dataStart, KV_CAP);
|
|
105
|
-
if (n < 0) return null;
|
|
106
|
-
return out.slice(0, n);
|
|
71
|
+
@data
|
|
72
|
+
class Username {
|
|
73
|
+
name: string = '';
|
|
74
|
+
constructor(name: string = '') {
|
|
75
|
+
this.name = name;
|
|
76
|
+
}
|
|
107
77
|
}
|
|
108
78
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
79
|
+
@data
|
|
80
|
+
class ChallengeId {
|
|
81
|
+
cid: Uint8Array = new Uint8Array(0);
|
|
82
|
+
constructor(cid: Uint8Array = new Uint8Array(0)) {
|
|
83
|
+
this.cid = cid;
|
|
84
|
+
}
|
|
114
85
|
}
|
|
115
86
|
|
|
116
|
-
|
|
117
|
-
class
|
|
87
|
+
@data
|
|
88
|
+
class AuthAccount {
|
|
118
89
|
username: string = '';
|
|
119
90
|
salt: Uint8Array = new Uint8Array(0);
|
|
120
91
|
publicKey: Uint8Array = new Uint8Array(0);
|
|
@@ -123,30 +94,7 @@ class Account {
|
|
|
123
94
|
parallelism: u32 = 0;
|
|
124
95
|
}
|
|
125
96
|
|
|
126
|
-
|
|
127
|
-
const w = new DataWriter();
|
|
128
|
-
w.writeString(a.username);
|
|
129
|
-
w.writeBytes(a.salt);
|
|
130
|
-
w.writeBytes(a.publicKey);
|
|
131
|
-
w.writeU32(a.memKiB);
|
|
132
|
-
w.writeU32(a.iterations);
|
|
133
|
-
w.writeU32(a.parallelism);
|
|
134
|
-
kvPut(acctKey(a.username), w.toBytes());
|
|
135
|
-
}
|
|
136
|
-
function getAccount(username: string): Account | null {
|
|
137
|
-
const raw = kvGet(acctKey(username));
|
|
138
|
-
if (raw == null) return null;
|
|
139
|
-
const r = new DataReader(raw);
|
|
140
|
-
const a = new Account();
|
|
141
|
-
a.username = r.readString();
|
|
142
|
-
a.salt = r.readBytes();
|
|
143
|
-
a.publicKey = r.readBytes();
|
|
144
|
-
a.memKiB = r.readU32();
|
|
145
|
-
a.iterations = r.readU32();
|
|
146
|
-
a.parallelism = r.readU32();
|
|
147
|
-
return r.ok ? a : null;
|
|
148
|
-
}
|
|
149
|
-
|
|
97
|
+
@data
|
|
150
98
|
class Challenge {
|
|
151
99
|
cid: Uint8Array = new Uint8Array(0);
|
|
152
100
|
username: string = '';
|
|
@@ -155,26 +103,10 @@ class Challenge {
|
|
|
155
103
|
exp: u64 = 0;
|
|
156
104
|
}
|
|
157
105
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
w.writeBytes(c.nonce);
|
|
163
|
-
w.writeU64(c.iat);
|
|
164
|
-
w.writeU64(c.exp);
|
|
165
|
-
kvPut(chalKey(c.cid), w.toBytes());
|
|
166
|
-
}
|
|
167
|
-
function consumeChallenge(cid: Uint8Array): Challenge | null {
|
|
168
|
-
const raw = kvGetDel(chalKey(cid));
|
|
169
|
-
if (raw == null) return null;
|
|
170
|
-
const r = new DataReader(raw);
|
|
171
|
-
const c = new Challenge();
|
|
172
|
-
c.cid = r.readBytes();
|
|
173
|
-
c.username = r.readString();
|
|
174
|
-
c.nonce = r.readBytes();
|
|
175
|
-
c.iat = r.readU64();
|
|
176
|
-
c.exp = r.readU64();
|
|
177
|
-
return r.ok ? c : null;
|
|
106
|
+
@database
|
|
107
|
+
class AuthDb {
|
|
108
|
+
@collection accounts!: Record<AuthAccount, Username>;
|
|
109
|
+
@collection challenges!: Record<Challenge, ChallengeId>;
|
|
178
110
|
}
|
|
179
111
|
|
|
180
112
|
@rest('auth')
|
|
@@ -214,7 +146,7 @@ class Auth {
|
|
|
214
146
|
if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
|
|
215
147
|
// Already registered: a distinguishable status (not the generic 401) so the
|
|
216
148
|
// client can say "username taken, log in instead" rather than a blank error.
|
|
217
|
-
if (
|
|
149
|
+
if (AuthDb.accounts.exists(new Username(username))) {
|
|
218
150
|
return Response.bytes(new DataWriter().writeU8(1).toBytes());
|
|
219
151
|
}
|
|
220
152
|
|
|
@@ -223,14 +155,17 @@ class Auth {
|
|
|
223
155
|
const regMsg = AuthService.buildRegisterMessage(username, pk);
|
|
224
156
|
if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
|
|
225
157
|
|
|
226
|
-
const a = new
|
|
158
|
+
const a = new AuthAccount();
|
|
227
159
|
a.username = username;
|
|
228
160
|
a.salt = deriveSalt(username);
|
|
229
161
|
a.publicKey = pk;
|
|
230
162
|
a.memKiB = DEMO_MEM_KIB;
|
|
231
163
|
a.iterations = DEMO_ITERS;
|
|
232
164
|
a.parallelism = DEMO_PAR;
|
|
233
|
-
|
|
165
|
+
// create-if-absent: a racing duplicate registration loses here, not above.
|
|
166
|
+
if (!AuthDb.accounts.create(new Username(username), a)) {
|
|
167
|
+
return Response.bytes(new DataWriter().writeU8(1).toBytes());
|
|
168
|
+
}
|
|
234
169
|
return Response.bytes(new DataWriter().writeU8(0).toBytes());
|
|
235
170
|
}
|
|
236
171
|
|
|
@@ -249,7 +184,7 @@ class Auth {
|
|
|
249
184
|
const evaluated = AuthService.oprfEvaluate(username, blinded);
|
|
250
185
|
if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
|
|
251
186
|
|
|
252
|
-
const
|
|
187
|
+
const known = AuthDb.accounts.exists(new Username(username));
|
|
253
188
|
const cid = randomBytes(16);
|
|
254
189
|
const nonce = randomBytes(32);
|
|
255
190
|
const iat = nowSecs();
|
|
@@ -257,14 +192,14 @@ class Auth {
|
|
|
257
192
|
|
|
258
193
|
// Persist only for a real account; the response is identical either way,
|
|
259
194
|
// and login/finish for an unknown user fails generically at consume.
|
|
260
|
-
if (
|
|
195
|
+
if (known) {
|
|
261
196
|
const c = new Challenge();
|
|
262
197
|
c.cid = cid;
|
|
263
198
|
c.username = username;
|
|
264
199
|
c.nonce = nonce;
|
|
265
200
|
c.iat = iat;
|
|
266
201
|
c.exp = exp;
|
|
267
|
-
|
|
202
|
+
AuthDb.challenges.create(new ChallengeId(cid), c);
|
|
268
203
|
}
|
|
269
204
|
|
|
270
205
|
const w = new DataWriter();
|
|
@@ -292,17 +227,26 @@ class Auth {
|
|
|
292
227
|
if (!r.ok) return fail();
|
|
293
228
|
|
|
294
229
|
// 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
|
|
295
|
-
const ch =
|
|
230
|
+
const ch = AuthDb.challenges.getDelete(new ChallengeId(cid));
|
|
296
231
|
if (ch == null) return fail();
|
|
297
232
|
if (nowSecs() >= ch.exp) return fail();
|
|
298
233
|
|
|
299
234
|
// 2. Rebuild the message from OUR stored values + the client's ct (and
|
|
300
235
|
// the bound params + server key id), load the account key, verify.
|
|
301
|
-
const acct =
|
|
236
|
+
const acct = AuthDb.accounts.get(new Username(ch.username));
|
|
302
237
|
if (acct == null) return fail();
|
|
303
238
|
const message = AuthService.buildLoginMessage(
|
|
304
|
-
ch.username,
|
|
305
|
-
|
|
239
|
+
ch.username,
|
|
240
|
+
AUD,
|
|
241
|
+
cid,
|
|
242
|
+
ch.nonce,
|
|
243
|
+
ch.iat,
|
|
244
|
+
ch.exp,
|
|
245
|
+
ct,
|
|
246
|
+
DEMO_MEM_KIB,
|
|
247
|
+
DEMO_ITERS,
|
|
248
|
+
DEMO_PAR,
|
|
249
|
+
AuthService.serverKemKeyId()
|
|
306
250
|
);
|
|
307
251
|
if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
|
|
308
252
|
|
|
@@ -34,9 +34,15 @@ class EnvDemo {
|
|
|
34
34
|
const apiKeySet = Environment.getSecure('DEMO_API_KEY') != null;
|
|
35
35
|
|
|
36
36
|
const body =
|
|
37
|
-
'PUBLIC_GREETING=' +
|
|
38
|
-
|
|
39
|
-
'
|
|
37
|
+
'PUBLIC_GREETING=' +
|
|
38
|
+
greeting +
|
|
39
|
+
'\n' +
|
|
40
|
+
'REGION=' +
|
|
41
|
+
region +
|
|
42
|
+
'\n' +
|
|
43
|
+
'DEMO_API_KEY set=' +
|
|
44
|
+
(apiKeySet ? 'yes' : 'no') +
|
|
45
|
+
'\n';
|
|
40
46
|
return Response.text(body, 200);
|
|
41
47
|
}
|
|
42
48
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Counter, Events } from 'toildb';
|
|
2
|
+
|
|
3
|
+
import { GuestEntry } from '../models/GuestEntry';
|
|
4
|
+
import { GuestbookView } from '../models/GuestbookView';
|
|
5
|
+
import { NewMessage } from '../models/NewMessage';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A PERSISTENT guestbook, mounted at `/guestbook`, backed by ToilDB.
|
|
9
|
+
*
|
|
10
|
+
* The contrast with `Players` (whose comment notes "memory resets next request")
|
|
11
|
+
* is the whole point: every signature is appended to an `events` stream and
|
|
12
|
+
* tallied in a `counter`, so the data SURVIVES across requests under `toiljs dev`
|
|
13
|
+
* (the in-process ToilDB emulator) and runs on ScyllaDB at the edge - same code,
|
|
14
|
+
* no connection string. On the client:
|
|
15
|
+
*
|
|
16
|
+
* await Server.REST.guestbook.sign({ body: new NewMessage('Ada', 'hi!') });
|
|
17
|
+
* const book = await Server.REST.guestbook.list(); // { total, entries: [...] }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// The guestbook is one global stream; a single fixed key addresses it.
|
|
21
|
+
@data
|
|
22
|
+
class GuestKey {
|
|
23
|
+
room: string = 'main';
|
|
24
|
+
constructor(room: string = 'main') {
|
|
25
|
+
this.room = room;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@database
|
|
30
|
+
class GuestbookDb {
|
|
31
|
+
@collection entries!: Events<GuestEntry, GuestKey>;
|
|
32
|
+
@collection totals!: Counter<GuestKey>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The current total + the 10 newest entries. */
|
|
36
|
+
function snapshot(): GuestbookView {
|
|
37
|
+
const key = new GuestKey('main');
|
|
38
|
+
const view = new GuestbookView();
|
|
39
|
+
view.total = GuestbookDb.totals.get(key);
|
|
40
|
+
view.entries = GuestbookDb.entries.latest(key, 10);
|
|
41
|
+
return view;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@rest('guestbook')
|
|
45
|
+
class Guestbook {
|
|
46
|
+
/** `GET /guestbook` - the running total + the most recent signatures. */
|
|
47
|
+
@get('/')
|
|
48
|
+
public list(): GuestbookView {
|
|
49
|
+
return snapshot();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `POST /guestbook` - append a signature (PERSISTED) and return the
|
|
53
|
+
* updated book. Sign twice and the total keeps climbing across requests. */
|
|
54
|
+
@post('/')
|
|
55
|
+
public sign(input: NewMessage): GuestbookView {
|
|
56
|
+
const key = new GuestKey('main');
|
|
57
|
+
const at = <u64>(Date.now() / 1000);
|
|
58
|
+
GuestbookDb.entries.append(key, new GuestEntry(input.author, input.message, at));
|
|
59
|
+
GuestbookDb.totals.add(key, 1);
|
|
60
|
+
return snapshot();
|
|
61
|
+
}
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toiljs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.56",
|
|
5
5
|
"author": "Dacely",
|
|
6
6
|
"description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
|
|
7
7
|
"repository": {
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
"nodemailer": "^9.0.0",
|
|
132
132
|
"picocolors": "^1.1.1",
|
|
133
133
|
"sharp": "^0.35.0",
|
|
134
|
-
"toilscript": "^0.1.
|
|
134
|
+
"toilscript": "^0.1.28",
|
|
135
135
|
"typescript-eslint": "^8.60.0",
|
|
136
136
|
"vite": "^8.0.14",
|
|
137
137
|
"vite-imagetools": "^10.0.0",
|
package/server/globals/auth.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// and the toiljs dev-server mock).
|
|
11
11
|
|
|
12
12
|
import { DataWriter, DataReader } from 'data';
|
|
13
|
-
import { HmacImportParams, HmacParams, ALG_SHA_256, USAGE_SIGN, USAGE_VERIFY } from 'crypto';
|
|
13
|
+
import { HmacImportParams, HmacParams, ALG_SHA_256, FMT_RAW, USAGE_SIGN, USAGE_VERIFY } from 'crypto';
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
Server,
|
|
@@ -160,7 +160,7 @@ function __resolveServerKemPk(): Uint8Array {
|
|
|
160
160
|
// both keyed PRFs over the transcript; the client mirrors this with hash-wasm.
|
|
161
161
|
function __hmacSha256(key: Uint8Array, msg: Uint8Array): Uint8Array {
|
|
162
162
|
const k = crypto.subtle.importKey(
|
|
163
|
-
|
|
163
|
+
FMT_RAW,
|
|
164
164
|
key,
|
|
165
165
|
new HmacImportParams(ALG_SHA_256),
|
|
166
166
|
false,
|
|
@@ -511,7 +511,7 @@ export namespace AuthService {
|
|
|
511
511
|
|
|
512
512
|
/** SHA-256 over `data` (ambient Web Crypto), for transcript/confirm hashing. */
|
|
513
513
|
export function sha256(data: Uint8Array): Uint8Array {
|
|
514
|
-
return crypto.subtle.digest(
|
|
514
|
+
return crypto.subtle.digest(ALG_SHA_256, data);
|
|
515
515
|
}
|
|
516
516
|
|
|
517
517
|
/** `SHA-256(serverKemPublicKey)` -- the key identity bound into the login
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
HmacImportParams,
|
|
25
25
|
HmacParams,
|
|
26
26
|
ALG_SHA_256,
|
|
27
|
+
FMT_RAW,
|
|
27
28
|
USAGE_SIGN,
|
|
28
29
|
USAGE_VERIFY,
|
|
29
30
|
} from 'crypto';
|
|
@@ -45,7 +46,7 @@ const TWOFA_VERSION: u8 = 1;
|
|
|
45
46
|
|
|
46
47
|
function importHmac(key: Uint8Array): CryptoKey {
|
|
47
48
|
return crypto.subtle.importKey(
|
|
48
|
-
|
|
49
|
+
FMT_RAW,
|
|
49
50
|
key,
|
|
50
51
|
new HmacImportParams(ALG_SHA_256),
|
|
51
52
|
false,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
HmacParams,
|
|
31
31
|
ALG_AES_GCM,
|
|
32
32
|
ALG_SHA_256,
|
|
33
|
+
FMT_RAW,
|
|
33
34
|
USAGE_SIGN,
|
|
34
35
|
USAGE_VERIFY,
|
|
35
36
|
USAGE_ENCRYPT,
|
|
@@ -104,7 +105,7 @@ export class SecureCookies {
|
|
|
104
105
|
|
|
105
106
|
private importHmac(key: Uint8Array): CryptoKey {
|
|
106
107
|
return crypto.subtle.importKey(
|
|
107
|
-
|
|
108
|
+
FMT_RAW,
|
|
108
109
|
key,
|
|
109
110
|
new HmacImportParams(ALG_SHA_256),
|
|
110
111
|
false,
|
|
@@ -114,7 +115,7 @@ export class SecureCookies {
|
|
|
114
115
|
|
|
115
116
|
private importAes(key: Uint8Array): CryptoKey {
|
|
116
117
|
return crypto.subtle.importKey(
|
|
117
|
-
|
|
118
|
+
FMT_RAW,
|
|
118
119
|
key,
|
|
119
120
|
new AesKeyParams(),
|
|
120
121
|
false,
|
package/src/backend/index.ts
CHANGED
|
@@ -10,10 +10,10 @@ import fs from 'node:fs';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
-
Server,
|
|
14
13
|
type MiddlewareNext,
|
|
15
14
|
type Request,
|
|
16
15
|
type Response,
|
|
16
|
+
Server,
|
|
17
17
|
type Websocket,
|
|
18
18
|
} from '@dacely/hyper-express';
|
|
19
19
|
|
|
@@ -172,7 +172,9 @@ export async function startBackend(options: BackendOptions): Promise<RunningBack
|
|
|
172
172
|
// default upgrade handler (hyper-express links it to the companion ws route). Same-origin and
|
|
173
173
|
// non-browser clients pass; others get 403.
|
|
174
174
|
app.upgrade(wsPath, (request: Request, response: Response) => {
|
|
175
|
-
if (
|
|
175
|
+
if (
|
|
176
|
+
!isWsOriginAllowed(request.headers.origin, request.headers.host, options.allowedOrigins)
|
|
177
|
+
) {
|
|
176
178
|
response.status(403).send();
|
|
177
179
|
return;
|
|
178
180
|
}
|
package/src/cli/doctor.ts
CHANGED
|
@@ -10,7 +10,12 @@ import { createRequire } from 'node:module';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
loadConfig,
|
|
15
|
+
type ResolvedToilConfig,
|
|
16
|
+
scanRoutes,
|
|
17
|
+
TOIL_SERVER_ENV_DTS,
|
|
18
|
+
} from 'toiljs/compiler';
|
|
14
19
|
|
|
15
20
|
import {
|
|
16
21
|
type Check,
|
|
@@ -41,8 +46,8 @@ import {
|
|
|
41
46
|
findRelativeAssets,
|
|
42
47
|
hasFailures,
|
|
43
48
|
type RestFacts,
|
|
44
|
-
type RpcFacts,
|
|
45
49
|
RPC_TOILSCRIPT_MIN,
|
|
50
|
+
type RpcFacts,
|
|
46
51
|
satisfiesMin,
|
|
47
52
|
type SourceFile,
|
|
48
53
|
summarize,
|
|
@@ -353,7 +358,9 @@ function applyRpcFix(root: string): RpcFixResult {
|
|
|
353
358
|
const serverToilconfig = readJsonObject(path.join(root, 'toilconfig.json'));
|
|
354
359
|
if (serverToilconfig !== null) {
|
|
355
360
|
const entries = Array.isArray(serverToilconfig.entries)
|
|
356
|
-
? (serverToilconfig.entries as unknown[]).filter(
|
|
361
|
+
? (serverToilconfig.entries as unknown[]).filter(
|
|
362
|
+
(e): e is string => typeof e === 'string',
|
|
363
|
+
)
|
|
357
364
|
: [];
|
|
358
365
|
const dirs = new Set<string>();
|
|
359
366
|
for (const e of entries) dirs.add(path.dirname(path.resolve(root, e)));
|
package/src/cli/notify.ts
CHANGED
|
@@ -13,12 +13,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
13
13
|
|
|
14
14
|
import { detectPackageManager } from './update.js';
|
|
15
15
|
import { accent, bold, box, dim, version as cliVersion, warn } from './ui.js';
|
|
16
|
-
import {
|
|
17
|
-
findOutdated,
|
|
18
|
-
isCacheFresh,
|
|
19
|
-
type OutdatedRow,
|
|
20
|
-
parseCheckCache,
|
|
21
|
-
} from './version-check.js';
|
|
16
|
+
import { findOutdated, isCacheFresh, type OutdatedRow, parseCheckCache } from './version-check.js';
|
|
22
17
|
|
|
23
18
|
const REGISTRY_URL = 'https://registry.npmjs.org/toiljs/latest';
|
|
24
19
|
const FETCH_TIMEOUT_MS = 2000;
|
package/src/cli/ui.ts
CHANGED
|
@@ -118,9 +118,7 @@ function visibleWidth(s: string): number {
|
|
|
118
118
|
export function box(lines: readonly string[], paint: (s: string) => string = (s) => s): string {
|
|
119
119
|
const width = lines.reduce((w, l) => Math.max(w, visibleWidth(l)), 0);
|
|
120
120
|
const side = paint('│');
|
|
121
|
-
const body = lines.map(
|
|
122
|
-
(l) => ` ${side} ${l}${' '.repeat(width - visibleWidth(l))} ${side}`,
|
|
123
|
-
);
|
|
121
|
+
const body = lines.map((l) => ` ${side} ${l}${' '.repeat(width - visibleWidth(l))} ${side}`);
|
|
124
122
|
return [
|
|
125
123
|
' ' + paint(`╭${'─'.repeat(width + 4)}╮`),
|
|
126
124
|
...body,
|
|
@@ -167,3 +165,5 @@ export function banner(): void {
|
|
|
167
165
|
const ver = `${dim(' v')}${brand(version())}`;
|
|
168
166
|
process.stdout.write('\n' + lines.join('\n') + '\n\n ' + tagline() + ' ' + ver + '\n\n');
|
|
169
167
|
}
|
|
168
|
+
|
|
169
|
+
|
package/src/cli/version-check.ts
CHANGED
|
@@ -29,7 +29,11 @@ export function parseCheckCache(raw: string): CheckCache | null {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** True when the cached answer is still trustworthy (also stale if the clock went backwards). */
|
|
32
|
-
export function isCacheFresh(
|
|
32
|
+
export function isCacheFresh(
|
|
33
|
+
cache: CheckCache,
|
|
34
|
+
now: number,
|
|
35
|
+
ttlMs: number = CHECK_TTL_MS,
|
|
36
|
+
): boolean {
|
|
33
37
|
return cache.checkedAt <= now && now - cache.checkedAt < ttlMs;
|
|
34
38
|
}
|
|
35
39
|
|
package/src/client/auth.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* JSON, byte-identical to the server's `AuthService.buildLoginMessage`.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { argon2id,
|
|
14
|
+
import { argon2id, createHMAC, createSHA256 } from 'hash-wasm';
|
|
15
15
|
import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
|
|
16
16
|
import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
|
|
17
17
|
import { ristretto255_oprf } from '@noble/curves/ed25519.js';
|
|
@@ -45,7 +45,9 @@ function fromHex(hex: string): Uint8Array {
|
|
|
45
45
|
* decapsulate, so a valid confirmation tag authenticates the server. This is
|
|
46
46
|
* the demo dev key; a real deployment pins its own (and rotates it).
|
|
47
47
|
*/
|
|
48
|
-
export const SERVER_KEM_PUBLIC_KEY = fromHex(
|
|
48
|
+
export const SERVER_KEM_PUBLIC_KEY = fromHex(
|
|
49
|
+
'29d765e8083182891302569b3712a856e564fdd484b0706b0c68568d5ab7edc742cf74459d64595455a60f267973aa55e43c5be61925a3822eafcca445e36dc4655636e31e6fc9bec338b253f94290008ef7f40dbddb49c15c690f6755a23a1b3c85cfd5207e71a607086a6fc6d74a05080f43276901a19cafdb8de7771d58ea07f0f1056b905127b22223d08e75173199f13ab13c5dcd3b51ac784f84e520484a262b845a897c41cf27324ab6ba545c78c9ccab361051e0bba53498af26240fa0d566d1572684f4b42e253e6d052c848650915063c35641e1121ef8d9cfd17b667b351103c56d195007c9376d0c08aa268396814490eab4c364175a94533267a1933862cc4c33bcf0a13d1fa2b9d6c5082eeca1480672f2526cbe013beff14dc908a386e0b633c8761023cbed760deac6709bc328d865ac82e12307b673d96711dbb27a4d939230d25b53d594169a318be0200fa33550e9418e2a3b30e9719edc09d5fc4306f1abfd021eab14637a8a72c5931d25dc9b56db0e6ab677522b10f25307dbb804a6774ce05b87b0976a4b227bfe6caf20a79e64004fbd27b1eea018b3ab8ffa629f2dc87f19278f95168e94e44660a3370c537795678eb2f056260609769740583b51b291862927a1938737c6a37f40b78f00671cccbcb88ac3427b37915ed58782998f84051647707d48995472baad3f64a7cca54e1c0734db08751c614a34f28b84f2c1b5a6817355ab61957c486b7acffbc092bc8a7b46387f33b53ed372f7168d31a71cd008539928b0cdf91e835aa97f6a2be6d327b87a6ae478701d75a59a25179cb14997bb2552853014724170a1c49b82c2bcebc3279024e1fa44c53c7afdc43f0bd22116490f3b74c90e7296be58b9a91168f2fa0c3d378a3bcac959f357825c9976a8c9ee944f29b45e96d7345d9b478431a20cf1c5d3a3227c717fd204619777636c0cb140db5c50d2a3302334461030bee34e4eb1a6f02b733f9ccda4290fa168bc039568373241542728d00030d1f251e83737cb215adbdc1de75978675a0cd0d75b12748abdda7a9852629c63697d145af2c69854b06e03f37c4b064e4c9a4c03f2ad4d081e70180e9547247921918118086b62b4f7727f46b24e3e79ba3f28209f32b5102035bf935856232f83642268c0292ec6bf8e9462382163d30a20b4bcb7b4439310ec9d0a148193907fc07697342967cf1a16c6b3c71558951fa915400736cf699262b54b723abb2ecc27b74b68ee494287595ef818388adb49e883c67bfa5c226c0eef037a0851a29d34675912c1ea1068310b6dfcd017c809c8fbfc2c3ae78dfef07299960eeefba182662a90fa422c1790f356a2ea909012b15623a9b9e450a282cb530589a68368b3583159d9010ac3e52cc974753c342e58279516339dfb691df94b13a223ad97eb6a09c21dafe6304a3642d6d2067b5238497661fe88ad1227ca3557be2a576b6e17c5a7f997ea07929e76407e376aba74c44cd8504804776f39bbb8327624188a63501e83b404d9438cade0b11dc3ac61856447fb072b91761c228878f01b2eb6b4b21ba664c2c75882431603b25a449ffeb8410b910558581777562aa9b2181fd9c04713ad9326462d3e842121c4997f9aa932417c67851625816de66e0d65637434629f39',
|
|
50
|
+
);
|
|
49
51
|
|
|
50
52
|
export const PUBLIC_KEY_LEN = 1312;
|
|
51
53
|
export const SECRET_KEY_LEN = 2560;
|
|
@@ -171,7 +173,6 @@ export function buildRegisterMessage(username: string, publicKey: Uint8Array): U
|
|
|
171
173
|
return new DataWriter().writeU8(1).writeString(username).writeBytes(publicKey).toBytes();
|
|
172
174
|
}
|
|
173
175
|
|
|
174
|
-
|
|
175
176
|
function decodeKdf(r: DataReader): KdfParams {
|
|
176
177
|
return {
|
|
177
178
|
memKiB: r.readU32(),
|
|
@@ -181,7 +182,6 @@ function decodeKdf(r: DataReader): KdfParams {
|
|
|
181
182
|
};
|
|
182
183
|
}
|
|
183
184
|
|
|
184
|
-
|
|
185
185
|
async function postBinary(baseUrl: string, path: string, body: Uint8Array): Promise<DataReader> {
|
|
186
186
|
const res = await fetch(baseUrl + path, {
|
|
187
187
|
method: 'POST',
|
|
@@ -204,7 +204,11 @@ export interface AuthOptions {
|
|
|
204
204
|
* stretched with Argon2id into an ML-DSA-44 keypair, and ONLY the public key
|
|
205
205
|
* (plus a proof-of-possession signature) is submitted. Throws on failure.
|
|
206
206
|
*/
|
|
207
|
-
export async function register(
|
|
207
|
+
export async function register(
|
|
208
|
+
username: string,
|
|
209
|
+
password: string,
|
|
210
|
+
opts: AuthOptions = {},
|
|
211
|
+
): Promise<void> {
|
|
208
212
|
const baseUrl = opts.baseUrl ?? '/auth';
|
|
209
213
|
const oprf = ristretto255_oprf.oprf;
|
|
210
214
|
const pw = utf8(password.normalize('NFKC'));
|
|
@@ -268,7 +272,11 @@ export async function register(username: string, password: string, opts: AuthOpt
|
|
|
268
272
|
* The secret key, seed, and shared secret are wiped as soon as they are used.
|
|
269
273
|
* Returns the opaque session token. Throws (one generic message) on any failure.
|
|
270
274
|
*/
|
|
271
|
-
export async function login(
|
|
275
|
+
export async function login(
|
|
276
|
+
username: string,
|
|
277
|
+
password: string,
|
|
278
|
+
opts: AuthOptions = {},
|
|
279
|
+
): Promise<Uint8Array> {
|
|
272
280
|
const baseUrl = opts.baseUrl ?? '/auth';
|
|
273
281
|
const oprf = ristretto255_oprf.oprf;
|
|
274
282
|
const pw = utf8(password.normalize('NFKC'));
|
|
@@ -299,8 +307,17 @@ export async function login(username: string, password: string, opts: AuthOption
|
|
|
299
307
|
const { cipherText, sharedSecret } = ml_kem768.encapsulate(SERVER_KEM_PUBLIC_KEY);
|
|
300
308
|
const serverKemKeyId = await sha256Bytes(SERVER_KEM_PUBLIC_KEY);
|
|
301
309
|
const message = buildLoginMessage(
|
|
302
|
-
username,
|
|
303
|
-
|
|
310
|
+
username,
|
|
311
|
+
aud,
|
|
312
|
+
cid,
|
|
313
|
+
nonce,
|
|
314
|
+
iat,
|
|
315
|
+
exp,
|
|
316
|
+
cipherText,
|
|
317
|
+
kdf.memKiB,
|
|
318
|
+
kdf.iterations,
|
|
319
|
+
kdf.parallelism,
|
|
320
|
+
serverKemKeyId,
|
|
304
321
|
);
|
|
305
322
|
let signature: Uint8Array;
|
|
306
323
|
try {
|
|
@@ -333,9 +350,15 @@ export async function login(username: string, password: string, opts: AuthOption
|
|
|
333
350
|
// decapsulated correctly derives the same K, so a valid tag proves its
|
|
334
351
|
// identity. Verify before returning the session.
|
|
335
352
|
const transcriptHash = await sha256Bytes(message);
|
|
336
|
-
const sessionKey = await hmacSha256(
|
|
353
|
+
const sessionKey = await hmacSha256(
|
|
354
|
+
sharedSecret,
|
|
355
|
+
concatBytes(utf8(SESSION_KEY_LABEL), transcriptHash),
|
|
356
|
+
);
|
|
337
357
|
wipe(sharedSecret);
|
|
338
|
-
const expected = await hmacSha256(
|
|
358
|
+
const expected = await hmacSha256(
|
|
359
|
+
sessionKey,
|
|
360
|
+
concatBytes(utf8(SERVER_CONFIRM_LABEL), transcriptHash),
|
|
361
|
+
);
|
|
339
362
|
if (!bytesEqual(expected, serverConfirm)) throw new Error('auth: server authentication failed');
|
|
340
363
|
|
|
341
364
|
return session; // session token
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ReactNode, type SyntheticEvent, useRef } from 'react';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { type ActionState, type RevalidateTarget, useAction } from '../routing/action.js';
|
|
4
4
|
|
|
5
5
|
/** Props for {@link Form}. */
|
|
6
6
|
export interface FormProps {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ComponentPropsWithRef, type CSSProperties, type ReactNode, useState } from 'react';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Props for {@link Image}: every standard `<img>` attribute, plus toil's layout/loading controls.
|