toiljs 0.0.54 → 0.0.55
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/compiler/.tsbuildinfo +1 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/database.d.ts +8 -0
- package/build/devserver/database.js +334 -0
- package/build/devserver/host.d.ts +2 -0
- package/build/devserver/host.js +3 -2
- package/build/devserver/module.js +7 -1
- package/examples/basic/client/routes/rest.tsx +52 -1
- 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 +39 -103
- 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/devserver/database.ts +459 -0
- package/src/devserver/host.ts +9 -5
- package/src/devserver/module.ts +9 -3
- package/test/devserver-database.test.ts +304 -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);
|
|
@@ -77,44 +65,28 @@ function deriveSalt(username: string): Uint8Array {
|
|
|
77
65
|
}
|
|
78
66
|
|
|
79
67
|
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// @ts-ignore: decorator
|
|
84
|
-
@external('env', 'kv.get')
|
|
85
|
-
declare function __kvGet(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
|
|
86
|
-
// @ts-ignore: decorator
|
|
87
|
-
@external('env', 'kv.getdel')
|
|
88
|
-
declare function __kvGetDel(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
|
|
68
|
+
// ToilDB collections (the `kv.*` dev placeholder is gone). The key + value are
|
|
69
|
+
// `@data` types: the binary codec is generated, the host marshals it, and the
|
|
70
|
+
// challenge is consumed exactly once with `getDelete`.
|
|
89
71
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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);
|
|
72
|
+
@data
|
|
73
|
+
class Username {
|
|
74
|
+
name: string = '';
|
|
75
|
+
constructor(name: string = '') {
|
|
76
|
+
this.name = name;
|
|
77
|
+
}
|
|
107
78
|
}
|
|
108
79
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
80
|
+
@data
|
|
81
|
+
class ChallengeId {
|
|
82
|
+
cid: Uint8Array = new Uint8Array(0);
|
|
83
|
+
constructor(cid: Uint8Array = new Uint8Array(0)) {
|
|
84
|
+
this.cid = cid;
|
|
85
|
+
}
|
|
114
86
|
}
|
|
115
87
|
|
|
116
|
-
|
|
117
|
-
class
|
|
88
|
+
@data
|
|
89
|
+
class AuthAccount {
|
|
118
90
|
username: string = '';
|
|
119
91
|
salt: Uint8Array = new Uint8Array(0);
|
|
120
92
|
publicKey: Uint8Array = new Uint8Array(0);
|
|
@@ -123,30 +95,7 @@ class Account {
|
|
|
123
95
|
parallelism: u32 = 0;
|
|
124
96
|
}
|
|
125
97
|
|
|
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
|
-
|
|
98
|
+
@data
|
|
150
99
|
class Challenge {
|
|
151
100
|
cid: Uint8Array = new Uint8Array(0);
|
|
152
101
|
username: string = '';
|
|
@@ -155,26 +104,10 @@ class Challenge {
|
|
|
155
104
|
exp: u64 = 0;
|
|
156
105
|
}
|
|
157
106
|
|
|
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;
|
|
107
|
+
@database
|
|
108
|
+
class AuthDb {
|
|
109
|
+
@collection accounts!: Record<AuthAccount, Username>;
|
|
110
|
+
@collection challenges!: Record<Challenge, ChallengeId>;
|
|
178
111
|
}
|
|
179
112
|
|
|
180
113
|
@rest('auth')
|
|
@@ -214,7 +147,7 @@ class Auth {
|
|
|
214
147
|
if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
|
|
215
148
|
// Already registered: a distinguishable status (not the generic 401) so the
|
|
216
149
|
// client can say "username taken, log in instead" rather than a blank error.
|
|
217
|
-
if (
|
|
150
|
+
if (AuthDb.accounts.exists(new Username(username))) {
|
|
218
151
|
return Response.bytes(new DataWriter().writeU8(1).toBytes());
|
|
219
152
|
}
|
|
220
153
|
|
|
@@ -223,14 +156,17 @@ class Auth {
|
|
|
223
156
|
const regMsg = AuthService.buildRegisterMessage(username, pk);
|
|
224
157
|
if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
|
|
225
158
|
|
|
226
|
-
const a = new
|
|
159
|
+
const a = new AuthAccount();
|
|
227
160
|
a.username = username;
|
|
228
161
|
a.salt = deriveSalt(username);
|
|
229
162
|
a.publicKey = pk;
|
|
230
163
|
a.memKiB = DEMO_MEM_KIB;
|
|
231
164
|
a.iterations = DEMO_ITERS;
|
|
232
165
|
a.parallelism = DEMO_PAR;
|
|
233
|
-
|
|
166
|
+
// create-if-absent: a racing duplicate registration loses here, not above.
|
|
167
|
+
if (!AuthDb.accounts.create(new Username(username), a)) {
|
|
168
|
+
return Response.bytes(new DataWriter().writeU8(1).toBytes());
|
|
169
|
+
}
|
|
234
170
|
return Response.bytes(new DataWriter().writeU8(0).toBytes());
|
|
235
171
|
}
|
|
236
172
|
|
|
@@ -249,7 +185,7 @@ class Auth {
|
|
|
249
185
|
const evaluated = AuthService.oprfEvaluate(username, blinded);
|
|
250
186
|
if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
|
|
251
187
|
|
|
252
|
-
const
|
|
188
|
+
const known = AuthDb.accounts.exists(new Username(username));
|
|
253
189
|
const cid = randomBytes(16);
|
|
254
190
|
const nonce = randomBytes(32);
|
|
255
191
|
const iat = nowSecs();
|
|
@@ -257,14 +193,14 @@ class Auth {
|
|
|
257
193
|
|
|
258
194
|
// Persist only for a real account; the response is identical either way,
|
|
259
195
|
// and login/finish for an unknown user fails generically at consume.
|
|
260
|
-
if (
|
|
196
|
+
if (known) {
|
|
261
197
|
const c = new Challenge();
|
|
262
198
|
c.cid = cid;
|
|
263
199
|
c.username = username;
|
|
264
200
|
c.nonce = nonce;
|
|
265
201
|
c.iat = iat;
|
|
266
202
|
c.exp = exp;
|
|
267
|
-
|
|
203
|
+
AuthDb.challenges.create(new ChallengeId(cid), c);
|
|
268
204
|
}
|
|
269
205
|
|
|
270
206
|
const w = new DataWriter();
|
|
@@ -292,13 +228,13 @@ class Auth {
|
|
|
292
228
|
if (!r.ok) return fail();
|
|
293
229
|
|
|
294
230
|
// 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
|
|
295
|
-
const ch =
|
|
231
|
+
const ch = AuthDb.challenges.getDelete(new ChallengeId(cid));
|
|
296
232
|
if (ch == null) return fail();
|
|
297
233
|
if (nowSecs() >= ch.exp) return fail();
|
|
298
234
|
|
|
299
235
|
// 2. Rebuild the message from OUR stored values + the client's ct (and
|
|
300
236
|
// the bound params + server key id), load the account key, verify.
|
|
301
|
-
const acct =
|
|
237
|
+
const acct = AuthDb.accounts.get(new Username(ch.username));
|
|
302
238
|
if (acct == null) return fail();
|
|
303
239
|
const message = AuthService.buildLoginMessage(
|
|
304
240
|
ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp,
|
|
@@ -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.55",
|
|
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,
|