toiljs 0.0.51 → 0.0.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/TYPESCRIPT_LAW.md +12601 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +16 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +9 -20
- package/build/client/auth.js +112 -95
- package/build/client/index.d.ts +2 -2
- package/build/client/index.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +1 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +33 -0
- package/build/devserver/host.js +2 -0
- package/build/devserver/kv.d.ts +3 -0
- package/build/devserver/kv.js +53 -0
- package/build/devserver/module.js +2 -1
- package/docs/auth-todo.md +149 -0
- package/docs/auth.md +234 -173
- package/examples/basic/client/routes/pq.tsx +72 -103
- package/examples/basic/server/core/AppHandler.ts +24 -3
- package/examples/basic/server/main.ts +0 -1
- package/examples/basic/server/routes/Auth.ts +304 -99
- package/examples/basic/server/routes/Session.ts +5 -2
- package/package.json +2 -1
- package/server/globals/auth.ts +263 -10
- package/src/cli/diagnostics.ts +22 -0
- package/src/cli/doctor.ts +2 -0
- package/src/client/auth.ts +192 -174
- package/src/client/index.ts +2 -2
- package/src/compiler/generate.ts +1 -1
- package/src/devserver/crypto.ts +54 -0
- package/src/devserver/host.ts +6 -0
- package/src/devserver/kv.ts +93 -0
- package/src/devserver/module.ts +4 -1
- package/test/devserver-pqauth.test.ts +153 -0
- package/test/doctor.test.ts +22 -0
- package/test/pqauth-e2e.test.ts +207 -0
- package/examples/basic/server/routes/PqDemo.ts +0 -127
|
@@ -1,68 +1,80 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Response, RouteContext } from 'toiljs/server/runtime';
|
|
2
2
|
import { DataReader, DataWriter } from 'data';
|
|
3
3
|
|
|
4
|
+
import { encodeSessionUser } from './Session';
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Toil PQ-Auth: post-quantum password login, end-to-end and runnable under `toiljs dev`.
|
|
8
|
+
*
|
|
9
|
+
* The password never leaves the browser. The client blinds it through the
|
|
10
|
+
* server-keyed OPRF (precomputation-resistant keyed salt), stretches the OPRF
|
|
11
|
+
* output with Argon2id into an ML-DSA-44 keypair, and registers only the public
|
|
12
|
+
* key (+ a proof-of-possession). Login is a challenge-response that also runs an
|
|
13
|
+
* ML-KEM-768 key encapsulation: the server proves its identity by returning a
|
|
14
|
+
* confirmation tag only derivable from the decapsulated shared secret (mutual
|
|
15
|
+
* auth). See `server/globals/auth.ts` (the `AuthService` global) and the client
|
|
16
|
+
* half in `toiljs/client` (`Auth.register` / `Auth.login`).
|
|
9
17
|
*
|
|
10
|
-
* STORAGE
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* the
|
|
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.
|
|
18
|
+
* STORAGE: backed by the DEV-ONLY `kv.*` host imports (see
|
|
19
|
+
* `src/devserver/kv.ts`) so the register -> login chain spans requests under
|
|
20
|
+
* `toiljs dev`. REMOVE LATER: this is a stand-in; once toildb is implemented,
|
|
21
|
+
* the Accounts/Challenges stores move onto it and this goes away. `kv.*` is not
|
|
22
|
+
* on the production edge.
|
|
20
23
|
*
|
|
21
24
|
* Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
|
|
22
|
-
* The client half lives in `toiljs/client` (`Auth.register` / `Auth.login`).
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
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
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
// Demo-light Argon2id params (responsive in a browser tab). A real deployment
|
|
30
|
+
// uses >= 256 MiB / >= 3 iterations. The client derives against whatever it is
|
|
31
|
+
// handed, so this is the single source of truth.
|
|
32
|
+
const DEMO_MEM_KIB: u32 = 32768; // 32 MiB
|
|
33
|
+
const DEMO_ITERS: u32 = 2;
|
|
34
|
+
const DEMO_PAR: u32 = 1;
|
|
35
|
+
|
|
36
|
+
const CHALLENGE_TTL_SECS: u64 = 120;
|
|
37
|
+
const SESSION_TTL_SECS: u64 = 3600;
|
|
38
|
+
|
|
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));
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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;
|
|
44
56
|
}
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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');
|
|
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)));
|
|
56
62
|
}
|
|
63
|
+
return out;
|
|
57
64
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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);
|
|
65
76
|
}
|
|
77
|
+
return s;
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
function randomBytes(n: i32): Uint8Array {
|
|
@@ -71,114 +83,307 @@ function randomBytes(n: i32): Uint8Array {
|
|
|
71
83
|
return b;
|
|
72
84
|
}
|
|
73
85
|
|
|
86
|
+
function nowSecs(): u64 {
|
|
87
|
+
return <u64>(Date.now() / 1000);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One generic error on every failure path (anti-enumeration, anti-oracle). */
|
|
74
91
|
function fail(): Response {
|
|
75
|
-
// One generic error on every failure path (anti-enumeration, anti-oracle).
|
|
76
92
|
return Response.text('auth: request failed\n', 401);
|
|
77
93
|
}
|
|
78
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Deterministic per-user Argon2id salt (16 bytes). With the OPRF providing
|
|
97
|
+
* precomputation resistance, a public/deterministic salt is fine: it only needs
|
|
98
|
+
* to be unique per user (the OPRF output already differs per user). Making it
|
|
99
|
+
* deterministic means register and login agree with NO stored salt, and an
|
|
100
|
+
* unknown user yields the SAME stable salt as a known one would -- no
|
|
101
|
+
* enumeration oracle.
|
|
102
|
+
*/
|
|
103
|
+
function deriveSalt(username: string): Uint8Array {
|
|
104
|
+
return crypto.sha256Text('toil-demo-salt-v1:' + username).slice(0, 16);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
let __configured = false;
|
|
109
|
+
/** Lazily configure the auth-route crypto material (OPRF seed + server ML-KEM
|
|
110
|
+
* key) on first use; only the register/login handlers below read these.
|
|
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
|
+
|
|
135
|
+
|
|
136
|
+
// @ts-ignore: decorator
|
|
137
|
+
@external('env', 'kv.put')
|
|
138
|
+
declare function __kvPut(keyPtr: usize, keyLen: i32, valPtr: usize, valLen: i32): void;
|
|
139
|
+
// @ts-ignore: decorator
|
|
140
|
+
@external('env', 'kv.get')
|
|
141
|
+
declare function __kvGet(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
|
|
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);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function acctKey(username: string): Uint8Array {
|
|
166
|
+
return utf8('acct:' + username);
|
|
167
|
+
}
|
|
168
|
+
function chalKey(cid: Uint8Array): Uint8Array {
|
|
169
|
+
return utf8('chal:' + toHex(cid));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Account {
|
|
174
|
+
username: string = '';
|
|
175
|
+
salt: Uint8Array = new Uint8Array(0);
|
|
176
|
+
publicKey: Uint8Array = new Uint8Array(0);
|
|
177
|
+
memKiB: u32 = 0;
|
|
178
|
+
iterations: u32 = 0;
|
|
179
|
+
parallelism: u32 = 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function putAccount(a: Account): void {
|
|
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
|
+
|
|
206
|
+
class Challenge {
|
|
207
|
+
cid: Uint8Array = new Uint8Array(0);
|
|
208
|
+
username: string = '';
|
|
209
|
+
nonce: Uint8Array = new Uint8Array(0);
|
|
210
|
+
iat: u64 = 0;
|
|
211
|
+
exp: u64 = 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function putChallenge(c: Challenge): void {
|
|
215
|
+
const w = new DataWriter();
|
|
216
|
+
w.writeBytes(c.cid);
|
|
217
|
+
w.writeString(c.username);
|
|
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;
|
|
234
|
+
}
|
|
235
|
+
|
|
79
236
|
@rest('auth')
|
|
80
237
|
class Auth {
|
|
81
|
-
/** POST /auth/register/start body: str(username)
|
|
82
|
-
* resp: u8(status=0)
|
|
238
|
+
/** POST /auth/register/start body: str(username) bytes(blinded)
|
|
239
|
+
* resp: u8(status=0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)
|
|
240
|
+
* No taken-oracle: always succeeds; register/finish rejects a duplicate. */
|
|
83
241
|
@post('/register/start')
|
|
84
242
|
public registerStart(ctx: RouteContext): Response {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
243
|
+
ensureConfigured();
|
|
244
|
+
const r = new DataReader(ctx.request.body);
|
|
245
|
+
const username = r.readString();
|
|
246
|
+
const blinded = r.readBytes();
|
|
247
|
+
if (!r.ok) return fail();
|
|
248
|
+
const evaluated = AuthService.oprfEvaluate(username, blinded);
|
|
249
|
+
if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
|
|
250
|
+
|
|
90
251
|
const w = new DataWriter();
|
|
91
252
|
w.writeU8(0);
|
|
92
|
-
w.writeU32(
|
|
93
|
-
w.writeU32(
|
|
94
|
-
w.writeU32(
|
|
95
|
-
w.writeBytes(
|
|
96
|
-
|
|
97
|
-
// registerFinish stores the same one; omitted here (no store).
|
|
253
|
+
w.writeU32(DEMO_MEM_KIB);
|
|
254
|
+
w.writeU32(DEMO_ITERS);
|
|
255
|
+
w.writeU32(DEMO_PAR);
|
|
256
|
+
w.writeBytes(deriveSalt(username));
|
|
257
|
+
w.writeBytes(evaluated);
|
|
98
258
|
return Response.bytes(w.toBytes());
|
|
99
259
|
}
|
|
100
260
|
|
|
101
|
-
/** POST /auth/register/finish body: str(username) bytes(pk)
|
|
261
|
+
/** POST /auth/register/finish body: str(username) bytes(pk) bytes(regProof)
|
|
262
|
+
* resp: u8(status) -- 0 = ok, 1 = username already registered. Verifies
|
|
263
|
+
* proof-of-possession before storing the key. */
|
|
102
264
|
@post('/register/finish')
|
|
103
265
|
public registerFinish(ctx: RouteContext): Response {
|
|
266
|
+
ensureConfigured();
|
|
104
267
|
const r = new DataReader(ctx.request.body);
|
|
105
268
|
const username = r.readString();
|
|
106
269
|
const pk = r.readBytes();
|
|
107
|
-
|
|
108
|
-
|
|
270
|
+
const proof = r.readBytes();
|
|
271
|
+
if (!r.ok) return fail();
|
|
272
|
+
if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
|
|
273
|
+
// Already registered: a distinguishable status (not the generic 401) so the
|
|
274
|
+
// client can say "username taken, log in instead" rather than a blank error.
|
|
275
|
+
if (getAccount(username) != null) {
|
|
276
|
+
return Response.bytes(new DataWriter().writeU8(1).toBytes());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Proof-of-possession: the client signed buildRegisterMessage with the
|
|
280
|
+
// matching secret key, so we confirm it actually holds it.
|
|
281
|
+
const regMsg = AuthService.buildRegisterMessage(username, pk);
|
|
282
|
+
if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
|
|
283
|
+
|
|
284
|
+
const a = new Account();
|
|
109
285
|
a.username = username;
|
|
286
|
+
a.salt = deriveSalt(username);
|
|
110
287
|
a.publicKey = pk;
|
|
111
|
-
a.memKiB =
|
|
112
|
-
a.iterations =
|
|
113
|
-
a.parallelism =
|
|
114
|
-
|
|
115
|
-
Accounts.put(a);
|
|
288
|
+
a.memKiB = DEMO_MEM_KIB;
|
|
289
|
+
a.iterations = DEMO_ITERS;
|
|
290
|
+
a.parallelism = DEMO_PAR;
|
|
291
|
+
putAccount(a);
|
|
116
292
|
return Response.bytes(new DataWriter().writeU8(0).toBytes());
|
|
117
293
|
}
|
|
118
294
|
|
|
119
|
-
/** POST /auth/login/start body: str(username)
|
|
120
|
-
* resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt)
|
|
295
|
+
/** POST /auth/login/start body: str(username) bytes(blinded)
|
|
296
|
+
* resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt)
|
|
297
|
+
* bytes(nonce) u64(iat) u64(exp) bytes(evaluated)
|
|
298
|
+
* Anti-enumeration: ALWAYS OPRF-evaluates (real or decoy key from the same
|
|
299
|
+
* seed+username), returns a deterministic per-user salt + constant params,
|
|
300
|
+
* and a fresh challenge -- a known and an unknown user are indistinguishable. */
|
|
121
301
|
@post('/login/start')
|
|
122
302
|
public loginStart(ctx: RouteContext): Response {
|
|
123
|
-
|
|
124
|
-
const
|
|
303
|
+
ensureConfigured();
|
|
304
|
+
const r = new DataReader(ctx.request.body);
|
|
305
|
+
const username = r.readString();
|
|
306
|
+
const blinded = r.readBytes();
|
|
307
|
+
if (!r.ok) return fail();
|
|
308
|
+
const evaluated = AuthService.oprfEvaluate(username, blinded);
|
|
309
|
+
if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
|
|
125
310
|
|
|
311
|
+
const acct = getAccount(username);
|
|
126
312
|
const cid = randomBytes(16);
|
|
127
313
|
const nonce = randomBytes(32);
|
|
128
|
-
const iat =
|
|
129
|
-
const exp = iat +
|
|
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;
|
|
314
|
+
const iat = nowSecs();
|
|
315
|
+
const exp = iat + CHALLENGE_TTL_SECS;
|
|
137
316
|
|
|
317
|
+
// Persist only for a real account; the response is identical either way,
|
|
318
|
+
// and login/finish for an unknown user fails generically at consume.
|
|
138
319
|
if (acct != null) {
|
|
139
|
-
const c = new
|
|
320
|
+
const c = new Challenge();
|
|
140
321
|
c.cid = cid;
|
|
141
322
|
c.username = username;
|
|
142
323
|
c.nonce = nonce;
|
|
143
324
|
c.iat = iat;
|
|
144
325
|
c.exp = exp;
|
|
145
|
-
|
|
326
|
+
putChallenge(c);
|
|
146
327
|
}
|
|
147
328
|
|
|
148
329
|
const w = new DataWriter();
|
|
149
330
|
w.writeBytes(cid);
|
|
150
331
|
w.writeString(AUD);
|
|
151
|
-
w.writeU32(
|
|
152
|
-
w.writeU32(
|
|
153
|
-
w.writeU32(
|
|
154
|
-
w.writeBytes(
|
|
332
|
+
w.writeU32(DEMO_MEM_KIB);
|
|
333
|
+
w.writeU32(DEMO_ITERS);
|
|
334
|
+
w.writeU32(DEMO_PAR);
|
|
335
|
+
w.writeBytes(deriveSalt(username));
|
|
155
336
|
w.writeBytes(nonce);
|
|
156
337
|
w.writeU64(iat);
|
|
157
338
|
w.writeU64(exp);
|
|
339
|
+
w.writeBytes(evaluated);
|
|
158
340
|
return Response.bytes(w.toBytes());
|
|
159
341
|
}
|
|
160
342
|
|
|
161
|
-
/** POST /auth/login/finish body: bytes(cid) bytes(
|
|
343
|
+
/** POST /auth/login/finish body: bytes(cid) bytes(ct) bytes(sig)
|
|
344
|
+
* resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie */
|
|
162
345
|
@post('/login/finish')
|
|
163
346
|
public loginFinish(ctx: RouteContext): Response {
|
|
347
|
+
ensureConfigured();
|
|
164
348
|
const r = new DataReader(ctx.request.body);
|
|
165
349
|
const cid = r.readBytes();
|
|
350
|
+
const ct = r.readBytes();
|
|
166
351
|
const sig = r.readBytes();
|
|
352
|
+
if (!r.ok) return fail();
|
|
167
353
|
|
|
168
354
|
// 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
|
|
169
|
-
const ch =
|
|
355
|
+
const ch = consumeChallenge(cid);
|
|
170
356
|
if (ch == null) return fail();
|
|
171
|
-
|
|
172
|
-
if (now >= ch.exp) return fail();
|
|
357
|
+
if (nowSecs() >= ch.exp) return fail();
|
|
173
358
|
|
|
174
|
-
// 2. Rebuild the message from OUR stored values
|
|
175
|
-
//
|
|
176
|
-
const acct =
|
|
359
|
+
// 2. Rebuild the message from OUR stored values + the client's ct (and
|
|
360
|
+
// the bound params + server key id), load the account key, verify.
|
|
361
|
+
const acct = getAccount(ch.username);
|
|
177
362
|
if (acct == null) return fail();
|
|
178
|
-
const message = AuthService.buildLoginMessage(
|
|
363
|
+
const message = AuthService.buildLoginMessage(
|
|
364
|
+
ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp,
|
|
365
|
+
ct, DEMO_MEM_KIB, DEMO_ITERS, DEMO_PAR, AuthService.serverKemKeyId(),
|
|
366
|
+
);
|
|
179
367
|
if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
|
|
180
368
|
|
|
181
|
-
// 3.
|
|
182
|
-
|
|
369
|
+
// 3. Decapsulate (proves WE hold the KEM key), derive the session key K
|
|
370
|
+
// bound to the transcript, and build the confirmation tag the client
|
|
371
|
+
// verifies for mutual auth.
|
|
372
|
+
const sharedSecret = AuthService.mlkemDecapsulate(ct);
|
|
373
|
+
if (sharedSecret.length != AuthService.SHARED_SECRET_LEN) return fail();
|
|
374
|
+
const transcriptHash = AuthService.sha256(message);
|
|
375
|
+
const sessionKey = AuthService.deriveSessionKey(sharedSecret, transcriptHash);
|
|
376
|
+
const confirm = AuthService.serverConfirmTag(sessionKey, transcriptHash);
|
|
377
|
+
|
|
378
|
+
// 4. Success: mint the session and return {0, sessionToken, confirm}.
|
|
379
|
+
const userData = encodeSessionUser(ch.username);
|
|
380
|
+
const w = new DataWriter();
|
|
381
|
+
w.writeU8(0);
|
|
382
|
+
w.writeBytes(userData); // opaque session token (the readable user payload)
|
|
383
|
+
w.writeBytes(confirm);
|
|
384
|
+
const resp = Response.bytes(w.toBytes());
|
|
385
|
+
resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
|
|
386
|
+
resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
|
|
387
|
+
return resp;
|
|
183
388
|
}
|
|
184
389
|
}
|
|
@@ -16,8 +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
|
-
* The
|
|
20
|
-
*
|
|
19
|
+
* The session secret is configured once per request, BEFORE routing, in
|
|
20
|
+
* `core/AppHandler` (every request runs in a fresh wasm instance, so the secret
|
|
21
|
+
* must be set on each one, and the mint side and this `@auth` verify side are
|
|
22
|
+
* different routes). It reads `AUTH_SESSION_SECRET` from the env store with a
|
|
23
|
+
* clearly-insecure DEV fallback; a real deployment sets it in `.env.secrets`.
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
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.53",
|
|
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": {
|
|
@@ -118,6 +118,7 @@
|
|
|
118
118
|
"dependencies": {
|
|
119
119
|
"@dacely/hyper-express": "6.17.4",
|
|
120
120
|
"@dacely/noble-post-quantum": "^0.6.1",
|
|
121
|
+
"@noble/curves": "^2.2.0",
|
|
121
122
|
"@dacely/toilscript-loader": "^0.1.0",
|
|
122
123
|
"@eslint-react/eslint-plugin": "^5.8.8",
|
|
123
124
|
"@eslint/js": "^10.0.1",
|