toiljs 0.0.53 → 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 +10 -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/core/AppHandler.ts +0 -18
- 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 -163
- package/examples/basic/server/routes/Guestbook.ts +62 -0
- package/examples/basic/server/routes/Session.ts +5 -5
- package/package.json +2 -2
- package/server/globals/auth.ts +113 -57
- 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,47 +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
|
-
// DEV fallback for the server ML-KEM-768 secret (decapsulation) key, hex, used
|
|
40
|
-
// only when `AUTH_KEM_SK` is not set in `.env.secrets`. Matches the PINNED public
|
|
41
|
-
// key in `src/client/auth.ts`. A real deployment sets AUTH_KEM_SK (and pins its
|
|
42
|
-
// own public key in the client) and rotates it; never ship a real one here.
|
|
43
|
-
const DEMO_KEM_SK_HEX: string = '3156a8eb11c62bdb4af9fc57bef470f880ae340373bcc61662748a9742a639b9ad6bc55a77a82e0caa99ede237b4783ce70ab08ecc5802a9478c4ca3de67acd7a2147db43fdba408e9765443f37e9e90cc09f836d53879b890126bd6c33d55a6d97636a28ba10e18ac919aa9d37c2e4d07b6c930a5cb3238c8338fbb1abe7dac124c93462ebc5ae81cb132947993a74f9602610eab68b7fc9407b58e958aca054443246240c484c650962408168632c303cfc738d3b918ee04a37c2436b6f7300b8c6e7bd528bc5c229673c3a1bc4ae4265772f654ed8377b285626c67a4ef715a5a04a56804c3fae93ca5e3219cd68649622ee0d77bcb664a68e377260a3a38c2739b81c3c9ec510b66acde5041f3b52922a17019dc9afaec71c3e3c3102686ceb019da138b22463ad7f452640526d1d8b21c9111ca844149d1391c937b84287f1a228342c06ccb87c31cb14227e175007c5c4497c11e8647377234a84ab2640aa8ee7acb54954f99155cf7d768446b104ac149f59ca1d0029401570db9341c93db0041d52fbbd62726a75f9ab177e4ea5176e675d28a1f9852c28b38074c91cec8064b6ba116db8b59c0434fbd1b207cd921fbf29b06740b53c7304b17b253652ad469b2cb10bf7ed3bcc5b1b6168c2d30a889f67a01ae79455100ac582ba2f764a4a4b134b9115d7c548032d55d4916ce25c0ce7c42160e446298fb10f747302e781a70b2b7962b0b54f3c0e3a4677e99cc02e41e66b0861d02d072b94ce3f8a04fd20d2ec220cea3737922808f00080186421e60b7d1076e5ca40099d54da33033021349e31bb65e12aa259b37bc975582aa6441ab2fabdc9cee0aab0c11c7e3489b93bab26e13bf399ab8a37949baba3c2f8a94fd97a9a551c96d582b5c1ba97b4547701656ee02567dd6a8362c1043c5874760c7d1133292f05c9d3689beccb903d4bd65f09e3e3255d0229daf9050ebaa107e51371fc9248393239575466a9c45b4a239e1b29b07d9701cf1bb488a95a004a98fcb1f6d548cc8554a3eb25a5fc90892618e5d33b04938567e748ab9ba79b0d39d611864b2140666c1791e79c5c0943a03038f7306551db3b271b08dec32443ae14674e16d6c42956ef36499348e7424bbc4883c37675a4f8bb28cd68f30b532ba80104e7214b9a4886045a152d161821a006ae03ae3742e36f63d997c858b850119e1004f4022a04a9533749d993641763a83dce5256f3826ae9b0584c72d69c77d6784444737a0192789e0d63a2f2808ce88b07c33383e588f68b13b892ac6998c9f2db14ba3e10eee4b9717761efc298e026974231a143b89009a724a7121292bb9292662b87502beadb9cbea3cc89de1997b376575f466b6693e18eb70630ba1823cae5f03698ae662190207156ca8d1a4a3cb926d20c92b524180c0804f057491c292024641bf9b21b52214bf2a2b42d16596e22935317bc712e64f64c143b257ca6f663223a1a2b6537b55746a2a739b2adbbfa004354a1555cc8b8215aa06413b27b7fa8c860386c13876b8d55b743860a13c0005dc4ac5e003cd3431c7a29edcc73c50b991e56a12423ac1f2842ed2999b7b31b6e01aaa83c01af658bae959b2cb256f1e7bba29d765e8083182891302569b3712a856e564fdd484b0706b0c68568d5ab7edc742cf74459d64595455a60f267973aa55e43c5be61925a3822eafcca445e36dc4655636e31e6fc9bec338b253f94290008ef7f40dbddb49c15c690f6755a23a1b3c85cfd5207e71a607086a6fc6d74a05080f43276901a19cafdb8de7771d58ea07f0f1056b905127b22223d08e75173199f13ab13c5dcd3b51ac784f84e520484a262b845a897c41cf27324ab6ba545c78c9ccab361051e0bba53498af26240fa0d566d1572684f4b42e253e6d052c848650915063c35641e1121ef8d9cfd17b667b351103c56d195007c9376d0c08aa268396814490eab4c364175a94533267a1933862cc4c33bcf0a13d1fa2b9d6c5082eeca1480672f2526cbe013beff14dc908a386e0b633c8761023cbed760deac6709bc328d865ac82e12307b673d96711dbb27a4d939230d25b53d594169a318be0200fa33550e9418e2a3b30e9719edc09d5fc4306f1abfd021eab14637a8a72c5931d25dc9b56db0e6ab677522b10f25307dbb804a6774ce05b87b0976a4b227bfe6caf20a79e64004fbd27b1eea018b3ab8ffa629f2dc87f19278f95168e94e44660a3370c537795678eb2f056260609769740583b51b291862927a1938737c6a37f40b78f00671cccbcb88ac3427b37915ed58782998f84051647707d48995472baad3f64a7cca54e1c0734db08751c614a34f28b84f2c1b5a6817355ab61957c486b7acffbc092bc8a7b46387f33b53ed372f7168d31a71cd008539928b0cdf91e835aa97f6a2be6d327b87a6ae478701d75a59a25179cb14997bb2552853014724170a1c49b82c2bcebc3279024e1fa44c53c7afdc43f0bd22116490f3b74c90e7296be58b9a91168f2fa0c3d378a3bcac959f357825c9976a8c9ee944f29b45e96d7345d9b478431a20cf1c5d3a3227c717fd204619777636c0cb140db5c50d2a3302334461030bee34e4eb1a6f02b733f9ccda4290fa168bc039568373241542728d00030d1f251e83737cb215adbdc1de75978675a0cd0d75b12748abdda7a9852629c63697d145af2c69854b06e03f37c4b064e4c9a4c03f2ad4d081e70180e9547247921918118086b62b4f7727f46b24e3e79ba3f28209f32b5102035bf935856232f83642268c0292ec6bf8e9462382163d30a20b4bcb7b4439310ec9d0a148193907fc07697342967cf1a16c6b3c71558951fa915400736cf699262b54b723abb2ecc27b74b68ee494287595ef818388adb49e883c67bfa5c226c0eef037a0851a29d34675912c1ea1068310b6dfcd017c809c8fbfc2c3ae78dfef07299960eeefba182662a90fa422c1790f356a2ea909012b15623a9b9e450a282cb530589a68368b3583159d9010ac3e52cc974753c342e58279516339dfb691df94b13a223ad97eb6a09c21dafe6304a3642d6d2067b5238497661fe88ad1227ca3557be2a576b6e17c5a7f997ea07929e76407e376aba74c44cd8504804776f39bbb8327624188a63501e83b404d9438cade0b11dc3ac61856447fb072b91761c228878f01b2eb6b4b21ba664c2c75882431603b25a449ffeb8410b910558581777562aa9b2181fd9c04713ad9326462d3e842121c4997f9aa932417c67851625816de66e0d65637434629f39d157cc40cbafccc4429c35caeda482299013baf565d0f38b8f2886b9641ae6bea5b2bfccd9e6f3000d1a2734414e5b6875828f9ca9b6c3d0ddeaf704111e2b38';
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
function utf8(s: string): Uint8Array {
|
|
47
|
-
return Uint8Array.wrap(String.UTF8.encode(s));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** A secret from the env store (`Environment.getSecure`, backed by `.env.secrets`),
|
|
51
|
-
* falling back to a DEV default so the example runs with zero config. Set the
|
|
52
|
-
* real value in `.env.secrets` for any non-throwaway use. */
|
|
53
|
-
function envSecretOr(key: string, devDefault: string): string {
|
|
54
|
-
const v = Environment.getSecure(key);
|
|
55
|
-
return v != null ? v : devDefault;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function fromHex(s: string): Uint8Array {
|
|
59
|
-
const out = new Uint8Array(s.length >> 1);
|
|
60
|
-
for (let i = 0; i < out.length; i++) {
|
|
61
|
-
out[i] = <u8>(hexNibble(s.charCodeAt(i * 2)) * 16 + hexNibble(s.charCodeAt(i * 2 + 1)));
|
|
62
|
-
}
|
|
63
|
-
return out;
|
|
64
|
-
}
|
|
65
|
-
function hexNibble(c: i32): i32 {
|
|
66
|
-
if (c >= 48 && c <= 57) return c - 48; // 0-9
|
|
67
|
-
if (c >= 97 && c <= 102) return c - 87; // a-f
|
|
68
|
-
if (c >= 65 && c <= 70) return c - 55; // A-F
|
|
69
|
-
return 0;
|
|
70
|
-
}
|
|
71
|
-
function toHex(b: Uint8Array): string {
|
|
72
|
-
let s = '';
|
|
73
|
-
for (let i = 0; i < b.length; i++) {
|
|
74
|
-
const v = b[i];
|
|
75
|
-
s += (v < 16 ? '0' : '') + (<u32>v).toString(16);
|
|
76
|
-
}
|
|
77
|
-
return s;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
40
|
function randomBytes(n: i32): Uint8Array {
|
|
81
41
|
const b = new Uint8Array(n);
|
|
82
42
|
crypto.getRandomValues(b);
|
|
@@ -105,72 +65,28 @@ function deriveSalt(username: string): Uint8Array {
|
|
|
105
65
|
}
|
|
106
66
|
|
|
107
67
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
*
|
|
112
|
-
* The session HMAC secret is deliberately NOT set here. The session is verified
|
|
113
|
-
* by the `@auth` gate in `routes/Session` (a DIFFERENT route -> a different fresh
|
|
114
|
-
* wasm instance per request), so it must be configured for EVERY request, not
|
|
115
|
-
* just auth ones. That happens once, before routing, in `core/AppHandler`. */
|
|
116
|
-
function ensureConfigured(): void {
|
|
117
|
-
if (__configured) return;
|
|
118
|
-
// Both secrets come from the env store (`.env.secrets`), with DEV fallbacks
|
|
119
|
-
// so the example runs with zero config. Set the real values in `.env.secrets`
|
|
120
|
-
// (see `.env.secrets.example`) for any non-throwaway use.
|
|
121
|
-
|
|
122
|
-
// OPRF master seed: hashed to a 32-byte (RFC 9497 Ns) seed so any env value
|
|
123
|
-
// works. Per-user OPRF keys derive from this + the username.
|
|
124
|
-
AuthService.setOprfSeed(crypto.sha256Text(envSecretOr('AUTH_OPRF_SEED', 'toil-demo-oprf-seed-v1')));
|
|
125
|
-
// Server static ML-KEM secret key (must match the client's pinned public key).
|
|
126
|
-
const sk = fromHex(envSecretOr('AUTH_KEM_SK', DEMO_KEM_SK_HEX));
|
|
127
|
-
AuthService.setServerKemSecretKey(sk);
|
|
128
|
-
// The ML-KEM-768 public key (ek) is embedded in the decapsulation key at
|
|
129
|
-
// bytes [1152, 2336) (FIPS 203 dk layout); use it for the key id the login
|
|
130
|
-
// message binds. Identical to the public key the client pins.
|
|
131
|
-
AuthService.setServerKemPublicKey(sk.slice(1152, 2336));
|
|
132
|
-
__configured = true;
|
|
133
|
-
}
|
|
134
|
-
|
|
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`.
|
|
135
71
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// @ts-ignore: decorator
|
|
143
|
-
@external('env', 'kv.getdel')
|
|
144
|
-
declare function __kvGetDel(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
|
|
145
|
-
|
|
146
|
-
const KV_CAP: i32 = 8192; // bounds account (~1.5 KiB) + challenge (~100 B) records
|
|
147
|
-
|
|
148
|
-
function kvPut(key: Uint8Array, val: Uint8Array): void {
|
|
149
|
-
__kvPut(key.dataStart, key.length, val.dataStart, val.length);
|
|
150
|
-
}
|
|
151
|
-
function kvGet(key: Uint8Array): Uint8Array | null {
|
|
152
|
-
const out = new Uint8Array(KV_CAP);
|
|
153
|
-
const n = __kvGet(key.dataStart, key.length, out.dataStart, KV_CAP);
|
|
154
|
-
if (n < 0) return null;
|
|
155
|
-
return out.slice(0, n);
|
|
156
|
-
}
|
|
157
|
-
/** Atomic fetch-and-delete: consumes a login challenge exactly once. */
|
|
158
|
-
function kvGetDel(key: Uint8Array): Uint8Array | null {
|
|
159
|
-
const out = new Uint8Array(KV_CAP);
|
|
160
|
-
const n = __kvGetDel(key.dataStart, key.length, out.dataStart, KV_CAP);
|
|
161
|
-
if (n < 0) return null;
|
|
162
|
-
return out.slice(0, n);
|
|
72
|
+
@data
|
|
73
|
+
class Username {
|
|
74
|
+
name: string = '';
|
|
75
|
+
constructor(name: string = '') {
|
|
76
|
+
this.name = name;
|
|
77
|
+
}
|
|
163
78
|
}
|
|
164
79
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
80
|
+
@data
|
|
81
|
+
class ChallengeId {
|
|
82
|
+
cid: Uint8Array = new Uint8Array(0);
|
|
83
|
+
constructor(cid: Uint8Array = new Uint8Array(0)) {
|
|
84
|
+
this.cid = cid;
|
|
85
|
+
}
|
|
170
86
|
}
|
|
171
87
|
|
|
172
|
-
|
|
173
|
-
class
|
|
88
|
+
@data
|
|
89
|
+
class AuthAccount {
|
|
174
90
|
username: string = '';
|
|
175
91
|
salt: Uint8Array = new Uint8Array(0);
|
|
176
92
|
publicKey: Uint8Array = new Uint8Array(0);
|
|
@@ -179,30 +95,7 @@ class Account {
|
|
|
179
95
|
parallelism: u32 = 0;
|
|
180
96
|
}
|
|
181
97
|
|
|
182
|
-
|
|
183
|
-
const w = new DataWriter();
|
|
184
|
-
w.writeString(a.username);
|
|
185
|
-
w.writeBytes(a.salt);
|
|
186
|
-
w.writeBytes(a.publicKey);
|
|
187
|
-
w.writeU32(a.memKiB);
|
|
188
|
-
w.writeU32(a.iterations);
|
|
189
|
-
w.writeU32(a.parallelism);
|
|
190
|
-
kvPut(acctKey(a.username), w.toBytes());
|
|
191
|
-
}
|
|
192
|
-
function getAccount(username: string): Account | null {
|
|
193
|
-
const raw = kvGet(acctKey(username));
|
|
194
|
-
if (raw == null) return null;
|
|
195
|
-
const r = new DataReader(raw);
|
|
196
|
-
const a = new Account();
|
|
197
|
-
a.username = r.readString();
|
|
198
|
-
a.salt = r.readBytes();
|
|
199
|
-
a.publicKey = r.readBytes();
|
|
200
|
-
a.memKiB = r.readU32();
|
|
201
|
-
a.iterations = r.readU32();
|
|
202
|
-
a.parallelism = r.readU32();
|
|
203
|
-
return r.ok ? a : null;
|
|
204
|
-
}
|
|
205
|
-
|
|
98
|
+
@data
|
|
206
99
|
class Challenge {
|
|
207
100
|
cid: Uint8Array = new Uint8Array(0);
|
|
208
101
|
username: string = '';
|
|
@@ -211,26 +104,10 @@ class Challenge {
|
|
|
211
104
|
exp: u64 = 0;
|
|
212
105
|
}
|
|
213
106
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
w.writeBytes(c.nonce);
|
|
219
|
-
w.writeU64(c.iat);
|
|
220
|
-
w.writeU64(c.exp);
|
|
221
|
-
kvPut(chalKey(c.cid), w.toBytes());
|
|
222
|
-
}
|
|
223
|
-
function consumeChallenge(cid: Uint8Array): Challenge | null {
|
|
224
|
-
const raw = kvGetDel(chalKey(cid));
|
|
225
|
-
if (raw == null) return null;
|
|
226
|
-
const r = new DataReader(raw);
|
|
227
|
-
const c = new Challenge();
|
|
228
|
-
c.cid = r.readBytes();
|
|
229
|
-
c.username = r.readString();
|
|
230
|
-
c.nonce = r.readBytes();
|
|
231
|
-
c.iat = r.readU64();
|
|
232
|
-
c.exp = r.readU64();
|
|
233
|
-
return r.ok ? c : null;
|
|
107
|
+
@database
|
|
108
|
+
class AuthDb {
|
|
109
|
+
@collection accounts!: Record<AuthAccount, Username>;
|
|
110
|
+
@collection challenges!: Record<Challenge, ChallengeId>;
|
|
234
111
|
}
|
|
235
112
|
|
|
236
113
|
@rest('auth')
|
|
@@ -240,7 +117,6 @@ class Auth {
|
|
|
240
117
|
* No taken-oracle: always succeeds; register/finish rejects a duplicate. */
|
|
241
118
|
@post('/register/start')
|
|
242
119
|
public registerStart(ctx: RouteContext): Response {
|
|
243
|
-
ensureConfigured();
|
|
244
120
|
const r = new DataReader(ctx.request.body);
|
|
245
121
|
const username = r.readString();
|
|
246
122
|
const blinded = r.readBytes();
|
|
@@ -263,7 +139,6 @@ class Auth {
|
|
|
263
139
|
* proof-of-possession before storing the key. */
|
|
264
140
|
@post('/register/finish')
|
|
265
141
|
public registerFinish(ctx: RouteContext): Response {
|
|
266
|
-
ensureConfigured();
|
|
267
142
|
const r = new DataReader(ctx.request.body);
|
|
268
143
|
const username = r.readString();
|
|
269
144
|
const pk = r.readBytes();
|
|
@@ -272,7 +147,7 @@ class Auth {
|
|
|
272
147
|
if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
|
|
273
148
|
// Already registered: a distinguishable status (not the generic 401) so the
|
|
274
149
|
// client can say "username taken, log in instead" rather than a blank error.
|
|
275
|
-
if (
|
|
150
|
+
if (AuthDb.accounts.exists(new Username(username))) {
|
|
276
151
|
return Response.bytes(new DataWriter().writeU8(1).toBytes());
|
|
277
152
|
}
|
|
278
153
|
|
|
@@ -281,14 +156,17 @@ class Auth {
|
|
|
281
156
|
const regMsg = AuthService.buildRegisterMessage(username, pk);
|
|
282
157
|
if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
|
|
283
158
|
|
|
284
|
-
const a = new
|
|
159
|
+
const a = new AuthAccount();
|
|
285
160
|
a.username = username;
|
|
286
161
|
a.salt = deriveSalt(username);
|
|
287
162
|
a.publicKey = pk;
|
|
288
163
|
a.memKiB = DEMO_MEM_KIB;
|
|
289
164
|
a.iterations = DEMO_ITERS;
|
|
290
165
|
a.parallelism = DEMO_PAR;
|
|
291
|
-
|
|
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
|
+
}
|
|
292
170
|
return Response.bytes(new DataWriter().writeU8(0).toBytes());
|
|
293
171
|
}
|
|
294
172
|
|
|
@@ -300,7 +178,6 @@ class Auth {
|
|
|
300
178
|
* and a fresh challenge -- a known and an unknown user are indistinguishable. */
|
|
301
179
|
@post('/login/start')
|
|
302
180
|
public loginStart(ctx: RouteContext): Response {
|
|
303
|
-
ensureConfigured();
|
|
304
181
|
const r = new DataReader(ctx.request.body);
|
|
305
182
|
const username = r.readString();
|
|
306
183
|
const blinded = r.readBytes();
|
|
@@ -308,7 +185,7 @@ class Auth {
|
|
|
308
185
|
const evaluated = AuthService.oprfEvaluate(username, blinded);
|
|
309
186
|
if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
|
|
310
187
|
|
|
311
|
-
const
|
|
188
|
+
const known = AuthDb.accounts.exists(new Username(username));
|
|
312
189
|
const cid = randomBytes(16);
|
|
313
190
|
const nonce = randomBytes(32);
|
|
314
191
|
const iat = nowSecs();
|
|
@@ -316,14 +193,14 @@ class Auth {
|
|
|
316
193
|
|
|
317
194
|
// Persist only for a real account; the response is identical either way,
|
|
318
195
|
// and login/finish for an unknown user fails generically at consume.
|
|
319
|
-
if (
|
|
196
|
+
if (known) {
|
|
320
197
|
const c = new Challenge();
|
|
321
198
|
c.cid = cid;
|
|
322
199
|
c.username = username;
|
|
323
200
|
c.nonce = nonce;
|
|
324
201
|
c.iat = iat;
|
|
325
202
|
c.exp = exp;
|
|
326
|
-
|
|
203
|
+
AuthDb.challenges.create(new ChallengeId(cid), c);
|
|
327
204
|
}
|
|
328
205
|
|
|
329
206
|
const w = new DataWriter();
|
|
@@ -344,7 +221,6 @@ class Auth {
|
|
|
344
221
|
* resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie */
|
|
345
222
|
@post('/login/finish')
|
|
346
223
|
public loginFinish(ctx: RouteContext): Response {
|
|
347
|
-
ensureConfigured();
|
|
348
224
|
const r = new DataReader(ctx.request.body);
|
|
349
225
|
const cid = r.readBytes();
|
|
350
226
|
const ct = r.readBytes();
|
|
@@ -352,13 +228,13 @@ class Auth {
|
|
|
352
228
|
if (!r.ok) return fail();
|
|
353
229
|
|
|
354
230
|
// 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
|
|
355
|
-
const ch =
|
|
231
|
+
const ch = AuthDb.challenges.getDelete(new ChallengeId(cid));
|
|
356
232
|
if (ch == null) return fail();
|
|
357
233
|
if (nowSecs() >= ch.exp) return fail();
|
|
358
234
|
|
|
359
235
|
// 2. Rebuild the message from OUR stored values + the client's ct (and
|
|
360
236
|
// the bound params + server key id), load the account key, verify.
|
|
361
|
-
const acct =
|
|
237
|
+
const acct = AuthDb.accounts.get(new Username(ch.username));
|
|
362
238
|
if (acct == null) return fail();
|
|
363
239
|
const message = AuthService.buildLoginMessage(
|
|
364
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
|
+
}
|
|
@@ -16,11 +16,11 @@ import { DataReader, DataWriter } from 'data';
|
|
|
16
16
|
* this `/session/dev-login` mints one for a caller-named demo user so the flow is
|
|
17
17
|
* runnable without the external account store the login example stubs out.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* No secret wiring is needed: `AuthService` reads `AUTH_SESSION_SECRET` from the
|
|
20
|
+
* env store automatically (with a clearly-insecure DEV fallback), so the cookie
|
|
21
|
+
* minted on login and re-verified here by the `@auth` gate always agree, even
|
|
22
|
+
* though each request runs in its own fresh wasm instance. A real deployment just
|
|
23
|
+
* sets `AUTH_SESSION_SECRET` in `.env.secrets`.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
// @user: the authenticated-user shape. Exactly one per program.
|
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",
|