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
package/src/client/auth.ts
CHANGED
|
@@ -11,13 +11,41 @@
|
|
|
11
11
|
* JSON, byte-identical to the server's `AuthService.buildLoginMessage`.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { argon2id,
|
|
14
|
+
import { argon2id, createSHA256, createHMAC } from 'hash-wasm';
|
|
15
15
|
import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
|
|
16
|
+
import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
|
|
17
|
+
import { ristretto255_oprf } from '@noble/curves/ed25519.js';
|
|
16
18
|
|
|
17
19
|
import { DataReader, DataWriter } from 'toiljs/io';
|
|
18
20
|
|
|
19
21
|
/** FIPS 204 signing context (domain separator). Byte-identical to the server. */
|
|
20
22
|
export const LOGIN_CONTEXT = 'qauth:login:v1';
|
|
23
|
+
/** Registration proof-of-possession context (binds a sig to "register"). */
|
|
24
|
+
export const REGISTER_CONTEXT = 'qauth:register:v1';
|
|
25
|
+
/** Domain separators for the session-key derivation and the confirmation tag.
|
|
26
|
+
* Byte-identical to the server. */
|
|
27
|
+
export const SESSION_KEY_LABEL = 'toil-session-key-v1';
|
|
28
|
+
export const SERVER_CONFIRM_LABEL = 'toil-server-confirm-v1';
|
|
29
|
+
|
|
30
|
+
/** ML-KEM-768 sizes (FIPS 203). */
|
|
31
|
+
export const KEM_PUBLIC_KEY_LEN = 1184;
|
|
32
|
+
export const KEM_CIPHERTEXT_LEN = 1088;
|
|
33
|
+
export const SHARED_SECRET_LEN = 32;
|
|
34
|
+
|
|
35
|
+
/** Lowercase-hex -> bytes. */
|
|
36
|
+
function fromHex(hex: string): Uint8Array {
|
|
37
|
+
const out = new Uint8Array(hex.length / 2);
|
|
38
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The server's PINNED static ML-KEM-768 public key. The client encapsulates to
|
|
44
|
+
* it; only the genuine server (holder of the matching secret key) can
|
|
45
|
+
* decapsulate, so a valid confirmation tag authenticates the server. This is
|
|
46
|
+
* the demo dev key; a real deployment pins its own (and rotates it).
|
|
47
|
+
*/
|
|
48
|
+
export const SERVER_KEM_PUBLIC_KEY = fromHex('29d765e8083182891302569b3712a856e564fdd484b0706b0c68568d5ab7edc742cf74459d64595455a60f267973aa55e43c5be61925a3822eafcca445e36dc4655636e31e6fc9bec338b253f94290008ef7f40dbddb49c15c690f6755a23a1b3c85cfd5207e71a607086a6fc6d74a05080f43276901a19cafdb8de7771d58ea07f0f1056b905127b22223d08e75173199f13ab13c5dcd3b51ac784f84e520484a262b845a897c41cf27324ab6ba545c78c9ccab361051e0bba53498af26240fa0d566d1572684f4b42e253e6d052c848650915063c35641e1121ef8d9cfd17b667b351103c56d195007c9376d0c08aa268396814490eab4c364175a94533267a1933862cc4c33bcf0a13d1fa2b9d6c5082eeca1480672f2526cbe013beff14dc908a386e0b633c8761023cbed760deac6709bc328d865ac82e12307b673d96711dbb27a4d939230d25b53d594169a318be0200fa33550e9418e2a3b30e9719edc09d5fc4306f1abfd021eab14637a8a72c5931d25dc9b56db0e6ab677522b10f25307dbb804a6774ce05b87b0976a4b227bfe6caf20a79e64004fbd27b1eea018b3ab8ffa629f2dc87f19278f95168e94e44660a3370c537795678eb2f056260609769740583b51b291862927a1938737c6a37f40b78f00671cccbcb88ac3427b37915ed58782998f84051647707d48995472baad3f64a7cca54e1c0734db08751c614a34f28b84f2c1b5a6817355ab61957c486b7acffbc092bc8a7b46387f33b53ed372f7168d31a71cd008539928b0cdf91e835aa97f6a2be6d327b87a6ae478701d75a59a25179cb14997bb2552853014724170a1c49b82c2bcebc3279024e1fa44c53c7afdc43f0bd22116490f3b74c90e7296be58b9a91168f2fa0c3d378a3bcac959f357825c9976a8c9ee944f29b45e96d7345d9b478431a20cf1c5d3a3227c717fd204619777636c0cb140db5c50d2a3302334461030bee34e4eb1a6f02b733f9ccda4290fa168bc039568373241542728d00030d1f251e83737cb215adbdc1de75978675a0cd0d75b12748abdda7a9852629c63697d145af2c69854b06e03f37c4b064e4c9a4c03f2ad4d081e70180e9547247921918118086b62b4f7727f46b24e3e79ba3f28209f32b5102035bf935856232f83642268c0292ec6bf8e9462382163d30a20b4bcb7b4439310ec9d0a148193907fc07697342967cf1a16c6b3c71558951fa915400736cf699262b54b723abb2ecc27b74b68ee494287595ef818388adb49e883c67bfa5c226c0eef037a0851a29d34675912c1ea1068310b6dfcd017c809c8fbfc2c3ae78dfef07299960eeefba182662a90fa422c1790f356a2ea909012b15623a9b9e450a282cb530589a68368b3583159d9010ac3e52cc974753c342e58279516339dfb691df94b13a223ad97eb6a09c21dafe6304a3642d6d2067b5238497661fe88ad1227ca3557be2a576b6e17c5a7f997ea07929e76407e376aba74c44cd8504804776f39bbb8327624188a63501e83b404d9438cade0b11dc3ac61856447fb072b91761c228878f01b2eb6b4b21ba664c2c75882431603b25a449ffeb8410b910558581777562aa9b2181fd9c04713ad9326462d3e842121c4997f9aa932417c67851625816de66e0d65637434629f39');
|
|
21
49
|
|
|
22
50
|
export const PUBLIC_KEY_LEN = 1312;
|
|
23
51
|
export const SECRET_KEY_LEN = 2560;
|
|
@@ -38,16 +66,6 @@ export interface KdfParams {
|
|
|
38
66
|
readonly salt: Uint8Array;
|
|
39
67
|
}
|
|
40
68
|
|
|
41
|
-
/** A server login challenge. */
|
|
42
|
-
export interface Challenge {
|
|
43
|
-
readonly cid: Uint8Array;
|
|
44
|
-
readonly aud: string;
|
|
45
|
-
readonly kdf: KdfParams;
|
|
46
|
-
readonly nonce: Uint8Array;
|
|
47
|
-
readonly iat: bigint;
|
|
48
|
-
readonly exp: bigint;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
69
|
/** Overwrite a secret buffer with random bytes, then zero. Best-effort: JS GC
|
|
52
70
|
* cannot scrub copies, so we never store or close over secrets beyond one call. */
|
|
53
71
|
function wipe(buf: Uint8Array): void {
|
|
@@ -55,10 +73,13 @@ function wipe(buf: Uint8Array): void {
|
|
|
55
73
|
buf.fill(0);
|
|
56
74
|
}
|
|
57
75
|
|
|
58
|
-
/** Argon2id(
|
|
59
|
-
|
|
76
|
+
/** Argon2id(oprfOutput, salt; m,t,p, len=32) -> 32-byte ML-DSA seed. The KDF
|
|
77
|
+
* input is the OPRF output (the keyed salt), NOT the raw password: the password
|
|
78
|
+
* is first run through the server-keyed OPRF, so a server breach yields no
|
|
79
|
+
* precomputable salt. */
|
|
80
|
+
async function deriveSeed(oprfOutput: Uint8Array, kdf: KdfParams): Promise<Uint8Array> {
|
|
60
81
|
return argon2id({
|
|
61
|
-
password:
|
|
82
|
+
password: oprfOutput,
|
|
62
83
|
salt: kdf.salt,
|
|
63
84
|
iterations: kdf.iterations,
|
|
64
85
|
parallelism: kdf.parallelism,
|
|
@@ -68,8 +89,52 @@ async function deriveSeed(password: string, kdf: KdfParams): Promise<Uint8Array>
|
|
|
68
89
|
});
|
|
69
90
|
}
|
|
70
91
|
|
|
71
|
-
|
|
72
|
-
|
|
92
|
+
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s);
|
|
93
|
+
|
|
94
|
+
/** SHA-256 over `data` -> 32 raw bytes. Uses hash-wasm (pure WASM), so it works
|
|
95
|
+
* in an insecure context (plain HTTP) where `crypto.subtle` is undefined. */
|
|
96
|
+
async function sha256Bytes(data: Uint8Array): Promise<Uint8Array> {
|
|
97
|
+
const h = await createSHA256();
|
|
98
|
+
h.init();
|
|
99
|
+
h.update(data);
|
|
100
|
+
return h.digest('binary') as unknown as Uint8Array;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** HMAC-SHA256(key, msg) -> 32 raw bytes, via hash-wasm (pure WASM, works in an
|
|
104
|
+
* insecure context). Mirrors the server's `AuthService` HMAC for the session-key
|
|
105
|
+
* derivation and the mutual-auth confirmation tag. */
|
|
106
|
+
async function hmacSha256(key: Uint8Array, msg: Uint8Array): Promise<Uint8Array> {
|
|
107
|
+
const h = await createHMAC(createSHA256(), key);
|
|
108
|
+
h.init();
|
|
109
|
+
h.update(msg);
|
|
110
|
+
return h.digest('binary') as unknown as Uint8Array;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
|
114
|
+
let n = 0;
|
|
115
|
+
for (const p of parts) n += p.length;
|
|
116
|
+
const out = new Uint8Array(n);
|
|
117
|
+
let off = 0;
|
|
118
|
+
for (const p of parts) {
|
|
119
|
+
out.set(p, off);
|
|
120
|
+
off += p.length;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Length-checked constant-time-ish equality (no early-exit on content). */
|
|
126
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
127
|
+
if (a.length !== b.length) return false;
|
|
128
|
+
let diff = 0;
|
|
129
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
130
|
+
return diff === 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The canonical login message `M` -- ONE fixed binary layout, byte-identical to
|
|
134
|
+
* the server's `AuthService.buildLoginMessage`. Binds the ML-KEM ciphertext (so
|
|
135
|
+
* the signature commits to the key encapsulation), the Argon2id params (so a
|
|
136
|
+
* MITM cannot slip a downgrade past the signature), and the server KEM key id
|
|
137
|
+
* (so it commits to which server key was encapsulated to). */
|
|
73
138
|
export function buildLoginMessage(
|
|
74
139
|
sub: string,
|
|
75
140
|
aud: string,
|
|
@@ -77,6 +142,11 @@ export function buildLoginMessage(
|
|
|
77
142
|
nonce: Uint8Array,
|
|
78
143
|
iat: bigint,
|
|
79
144
|
exp: bigint,
|
|
145
|
+
ciphertext: Uint8Array,
|
|
146
|
+
memKiB: number,
|
|
147
|
+
iterations: number,
|
|
148
|
+
parallelism: number,
|
|
149
|
+
serverKemKeyId: Uint8Array,
|
|
80
150
|
): Uint8Array {
|
|
81
151
|
return new DataWriter()
|
|
82
152
|
.writeU8(1)
|
|
@@ -86,10 +156,21 @@ export function buildLoginMessage(
|
|
|
86
156
|
.writeBytes(nonce)
|
|
87
157
|
.writeU64(iat)
|
|
88
158
|
.writeU64(exp)
|
|
159
|
+
.writeBytes(ciphertext)
|
|
160
|
+
.writeU32(memKiB)
|
|
161
|
+
.writeU32(iterations)
|
|
162
|
+
.writeU32(parallelism)
|
|
163
|
+
.writeBytes(serverKemKeyId)
|
|
89
164
|
.toBytes();
|
|
90
165
|
}
|
|
91
166
|
|
|
92
|
-
|
|
167
|
+
/** Registration proof-of-possession message: `u8(1) str(username) bytes(pk)`,
|
|
168
|
+
* signed under {@link REGISTER_CONTEXT}. Byte-identical to the server's
|
|
169
|
+
* `buildRegisterMessage`. */
|
|
170
|
+
export function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array {
|
|
171
|
+
return new DataWriter().writeU8(1).writeString(username).writeBytes(publicKey).toBytes();
|
|
172
|
+
}
|
|
173
|
+
|
|
93
174
|
|
|
94
175
|
function decodeKdf(r: DataReader): KdfParams {
|
|
95
176
|
return {
|
|
@@ -100,15 +181,6 @@ function decodeKdf(r: DataReader): KdfParams {
|
|
|
100
181
|
};
|
|
101
182
|
}
|
|
102
183
|
|
|
103
|
-
function decodeChallenge(r: DataReader): Challenge {
|
|
104
|
-
const cid = r.readBytes();
|
|
105
|
-
const aud = r.readString();
|
|
106
|
-
const kdf = decodeKdf(r);
|
|
107
|
-
const nonce = r.readBytes();
|
|
108
|
-
const iat = r.readU64();
|
|
109
|
-
const exp = r.readU64();
|
|
110
|
-
return { cid, aud, kdf, nonce, iat, exp };
|
|
111
|
-
}
|
|
112
184
|
|
|
113
185
|
async function postBinary(baseUrl: string, path: string, body: Uint8Array): Promise<DataReader> {
|
|
114
186
|
const res = await fetch(baseUrl + path, {
|
|
@@ -127,65 +199,114 @@ export interface AuthOptions {
|
|
|
127
199
|
}
|
|
128
200
|
|
|
129
201
|
/**
|
|
130
|
-
* Register a new account
|
|
131
|
-
*
|
|
202
|
+
* Register a new account. The password never leaves the browser:
|
|
203
|
+
* it is blinded and run through the server-keyed OPRF, the OPRF output is
|
|
204
|
+
* stretched with Argon2id into an ML-DSA-44 keypair, and ONLY the public key
|
|
205
|
+
* (plus a proof-of-possession signature) is submitted. Throws on failure.
|
|
132
206
|
*/
|
|
133
207
|
export async function register(username: string, password: string, opts: AuthOptions = {}): Promise<void> {
|
|
134
208
|
const baseUrl = opts.baseUrl ?? '/auth';
|
|
209
|
+
const oprf = ristretto255_oprf.oprf;
|
|
210
|
+
const pw = utf8(password.normalize('NFKC'));
|
|
135
211
|
|
|
136
|
-
// 1.
|
|
137
|
-
|
|
212
|
+
// 1. Blind the password and start registration: the server confirms the name
|
|
213
|
+
// is free, issues salt + KDF params, and OPRF-evaluates the blinded input.
|
|
214
|
+
const { blind, blinded } = oprf.blind(pw);
|
|
215
|
+
const start = await postBinary(
|
|
216
|
+
baseUrl,
|
|
217
|
+
'/register/start',
|
|
218
|
+
new DataWriter().writeString(username).writeBytes(blinded).toBytes(),
|
|
219
|
+
);
|
|
138
220
|
const status = start.readU8();
|
|
139
221
|
if (status !== 0) throw new Error('auth: registration unavailable');
|
|
140
222
|
const kdf = decodeKdf(start);
|
|
223
|
+
const evaluated = start.readBytes();
|
|
141
224
|
|
|
142
|
-
// 2.
|
|
143
|
-
|
|
225
|
+
// 2. Finalize the OPRF -> keyed salt -> seed -> keypair. Keep only the public
|
|
226
|
+
// key + a PoP signature; wipe the secret key and seed immediately.
|
|
227
|
+
const oprfOutput = oprf.finalize(pw, blind, evaluated);
|
|
228
|
+
const seed = await deriveSeed(oprfOutput, kdf);
|
|
144
229
|
let publicKey: Uint8Array;
|
|
230
|
+
let regProof: Uint8Array;
|
|
145
231
|
try {
|
|
146
232
|
const kp = ml_dsa44.keygen(seed);
|
|
147
233
|
publicKey = kp.publicKey;
|
|
148
|
-
|
|
234
|
+
try {
|
|
235
|
+
regProof = ml_dsa44.sign(buildRegisterMessage(username, publicKey), kp.secretKey, {
|
|
236
|
+
context: utf8(REGISTER_CONTEXT),
|
|
237
|
+
});
|
|
238
|
+
} finally {
|
|
239
|
+
wipe(kp.secretKey);
|
|
240
|
+
}
|
|
149
241
|
} finally {
|
|
150
242
|
wipe(seed);
|
|
151
243
|
}
|
|
152
244
|
if (publicKey.length !== PUBLIC_KEY_LEN) throw new Error('auth: bad public key length');
|
|
153
245
|
|
|
154
|
-
// 3. Submit the public key.
|
|
246
|
+
// 3. Submit the public key + proof-of-possession.
|
|
155
247
|
const finish = await postBinary(
|
|
156
248
|
baseUrl,
|
|
157
249
|
'/register/finish',
|
|
158
|
-
new DataWriter().writeString(username).writeBytes(publicKey).toBytes(),
|
|
250
|
+
new DataWriter().writeString(username).writeBytes(publicKey).writeBytes(regProof).toBytes(),
|
|
159
251
|
);
|
|
160
|
-
|
|
252
|
+
const finishStatus = finish.readU8();
|
|
253
|
+
if (finishStatus === 1) throw new Error('auth: username already registered (log in instead)');
|
|
254
|
+
if (finishStatus !== 0) throw new Error('auth: registration rejected');
|
|
161
255
|
}
|
|
162
256
|
|
|
163
257
|
/**
|
|
164
|
-
* Log in
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
258
|
+
* Log in (challenge-response with ML-KEM mutual auth). Steps:
|
|
259
|
+
* 1. Blind the password; `login/start` returns the challenge + the OPRF
|
|
260
|
+
* evaluation (a fully-formed response even for unknown users -> no oracle).
|
|
261
|
+
* 2. Finalize the OPRF -> keyed salt -> seed -> ML-DSA keypair.
|
|
262
|
+
* 3. Encapsulate a shared secret to the PINNED server ML-KEM public key, build
|
|
263
|
+
* the v2 message (which binds the ciphertext), and sign it once.
|
|
264
|
+
* 4. Submit `{cid, ct, signature}`; the server consumes the challenge, verifies
|
|
265
|
+
* the signature, decapsulates, and returns a confirmation tag.
|
|
266
|
+
* 5. Verify that tag against our own shared secret -> the server proved it
|
|
267
|
+
* holds the KEM secret key (mutual authentication).
|
|
268
|
+
* The secret key, seed, and shared secret are wiped as soon as they are used.
|
|
269
|
+
* Returns the opaque session token. Throws (one generic message) on any failure.
|
|
169
270
|
*/
|
|
170
271
|
export async function login(username: string, password: string, opts: AuthOptions = {}): Promise<Uint8Array> {
|
|
171
272
|
const baseUrl = opts.baseUrl ?? '/auth';
|
|
273
|
+
const oprf = ristretto255_oprf.oprf;
|
|
274
|
+
const pw = utf8(password.normalize('NFKC'));
|
|
172
275
|
|
|
173
|
-
// 1.
|
|
174
|
-
const
|
|
175
|
-
|
|
276
|
+
// 1. Blinded login/start.
|
|
277
|
+
const { blind, blinded } = oprf.blind(pw);
|
|
278
|
+
const r = await postBinary(
|
|
279
|
+
baseUrl,
|
|
280
|
+
'/login/start',
|
|
281
|
+
new DataWriter().writeString(username).writeBytes(blinded).toBytes(),
|
|
176
282
|
);
|
|
177
|
-
|
|
283
|
+
const cid = r.readBytes();
|
|
284
|
+
const aud = r.readString();
|
|
285
|
+
const kdf = decodeKdf(r);
|
|
286
|
+
const nonce = r.readBytes();
|
|
287
|
+
const iat = r.readU64();
|
|
288
|
+
const exp = r.readU64();
|
|
289
|
+
const evaluated = r.readBytes();
|
|
178
290
|
// Client-side fast-fail only; the server re-checks expiry authoritatively.
|
|
179
|
-
if (BigInt(Math.floor(Date.now() / 1000)) >=
|
|
180
|
-
|
|
181
|
-
// 2.
|
|
182
|
-
const
|
|
183
|
-
const seed = await deriveSeed(
|
|
291
|
+
if (BigInt(Math.floor(Date.now() / 1000)) >= exp) throw new Error('auth: challenge expired');
|
|
292
|
+
|
|
293
|
+
// 2. OPRF -> keyed salt -> seed.
|
|
294
|
+
const oprfOutput = oprf.finalize(pw, blind, evaluated);
|
|
295
|
+
const seed = await deriveSeed(oprfOutput, kdf);
|
|
296
|
+
|
|
297
|
+
// 3. Encapsulate to the pinned server KEM key; build + sign the message,
|
|
298
|
+
// which binds the ciphertext, the KDF params, and the server key id.
|
|
299
|
+
const { cipherText, sharedSecret } = ml_kem768.encapsulate(SERVER_KEM_PUBLIC_KEY);
|
|
300
|
+
const serverKemKeyId = await sha256Bytes(SERVER_KEM_PUBLIC_KEY);
|
|
301
|
+
const message = buildLoginMessage(
|
|
302
|
+
username, aud, cid, nonce, iat, exp,
|
|
303
|
+
cipherText, kdf.memKiB, kdf.iterations, kdf.parallelism, serverKemKeyId,
|
|
304
|
+
);
|
|
184
305
|
let signature: Uint8Array;
|
|
185
306
|
try {
|
|
186
307
|
const kp = ml_dsa44.keygen(seed);
|
|
187
308
|
try {
|
|
188
|
-
signature = ml_dsa44.sign(message, kp.secretKey, { context:
|
|
309
|
+
signature = ml_dsa44.sign(message, kp.secretKey, { context: utf8(LOGIN_CONTEXT) });
|
|
189
310
|
} finally {
|
|
190
311
|
wipe(kp.secretKey);
|
|
191
312
|
}
|
|
@@ -194,134 +315,31 @@ export async function login(username: string, password: string, opts: AuthOption
|
|
|
194
315
|
}
|
|
195
316
|
if (signature.length !== SIGNATURE_LEN) throw new Error('auth: bad signature length');
|
|
196
317
|
|
|
197
|
-
//
|
|
198
|
-
// rebuilds the message from its own stored values, and verifies.
|
|
318
|
+
// 4. Submit {cid, ct, signature}.
|
|
199
319
|
const res = await postBinary(
|
|
200
320
|
baseUrl,
|
|
201
321
|
'/login/finish',
|
|
202
|
-
new DataWriter().writeBytes(
|
|
322
|
+
new DataWriter().writeBytes(cid).writeBytes(cipherText).writeBytes(signature).toBytes(),
|
|
203
323
|
);
|
|
204
|
-
if (res.readU8() !== 0)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
/** Lowercase hex of `bytes`. */
|
|
209
|
-
function toHex(bytes: Uint8Array): string {
|
|
210
|
-
let s = '';
|
|
211
|
-
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
|
212
|
-
return s;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** The signed identity proof produced by {@link proveIdentity}. */
|
|
216
|
-
export interface IdentityProof {
|
|
217
|
-
/** The wire envelope to POST to `/pq/verify`: `str(sub) str(token)
|
|
218
|
-
* bytes(publicKey) bytes(signature)`, where `token` is the edge's
|
|
219
|
-
* HMAC-signed challenge. The server re-opens the token, rebuilds the login
|
|
220
|
-
* message from the values inside it, and `AuthService.verifyLogin`s it. */
|
|
221
|
-
readonly envelope: Uint8Array;
|
|
222
|
-
/** First bytes of the 1312-byte ML-DSA-44 public key, for display. */
|
|
223
|
-
readonly publicKeyHex: string;
|
|
224
|
-
/** First bytes of the SERVER-issued nonce that was signed, for display. */
|
|
225
|
-
readonly nonceHex: string;
|
|
226
|
-
/** Signature length (always 2420 for ML-DSA-44), for display. */
|
|
227
|
-
readonly signatureLen: number;
|
|
228
|
-
/** Argon2id wall-clock spent deriving the keypair, ms (for display). */
|
|
229
|
-
readonly deriveMs: number;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** Deterministic 16-byte Argon2id salt for the demo, so the same
|
|
233
|
-
* username + password always maps to the same identity (keypair).
|
|
234
|
-
* Uses hash-wasm's SHA-256 (pure WebAssembly), not `crypto.subtle`, so it
|
|
235
|
-
* works in an insecure context (plain HTTP), where `crypto.subtle` is
|
|
236
|
-
* undefined. */
|
|
237
|
-
async function demoSalt(username: string): Promise<Uint8Array> {
|
|
238
|
-
const hex = await sha256('pq-demo|' + username);
|
|
239
|
-
const out = new Uint8Array(16);
|
|
240
|
-
for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
241
|
-
return out;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* DEMO helper: run the full post-quantum challenge-response in the browser.
|
|
246
|
-
* Fetches a SERVER-issued challenge (`GET {baseUrl}/challenge`), stretches the
|
|
247
|
-
* password with Argon2id into an ML-DSA-44 keypair, signs the login message
|
|
248
|
-
* built from the SERVER's nonce/cid/iat/exp, and returns the wire envelope the
|
|
249
|
-
* edge verifies (`AuthService.verifyLogin`). The secret key and seed are wiped
|
|
250
|
-
* before returning; only the public key + signature leave the tab.
|
|
251
|
-
*
|
|
252
|
-
* The nonce is server-chosen and tamper-proof (the challenge token is
|
|
253
|
-
* HMAC-signed by the edge), so a client cannot pre-sign or substitute its own.
|
|
254
|
-
* It is still NOT the full production login -- there is no single-use consume, so
|
|
255
|
-
* within the challenge TTL a captured proof could be replayed; that needs an
|
|
256
|
-
* atomic store (see {@link login} and server/routes/Auth.ts). Demo-light
|
|
257
|
-
* Argon2id params (16 MiB / 2 passes) keep it responsive in a tab; a real
|
|
258
|
-
* deployment uses >= 256 MiB.
|
|
259
|
-
*/
|
|
260
|
-
export async function proveIdentity(
|
|
261
|
-
username: string,
|
|
262
|
-
password: string,
|
|
263
|
-
opts: { baseUrl?: string } = {},
|
|
264
|
-
): Promise<IdentityProof> {
|
|
265
|
-
const baseUrl = opts.baseUrl ?? '/pq';
|
|
266
|
-
|
|
267
|
-
// 1. Server-issued challenge: aud, cid, nonce, iat, exp, and the signed token.
|
|
268
|
-
const cres = await fetch(baseUrl + '/challenge', { credentials: 'same-origin' });
|
|
269
|
-
if (!cres.ok) throw new Error('pq: challenge request failed');
|
|
270
|
-
const cr = new DataReader(new Uint8Array(await cres.arrayBuffer()));
|
|
271
|
-
const aud = cr.readString();
|
|
272
|
-
const cid = cr.readBytes();
|
|
273
|
-
const nonce = cr.readBytes();
|
|
274
|
-
const iat = cr.readU64();
|
|
275
|
-
const exp = cr.readU64();
|
|
276
|
-
const token = cr.readString();
|
|
277
|
-
|
|
278
|
-
// 2. Derive the keypair and sign the message built from the SERVER's values.
|
|
279
|
-
const salt = await demoSalt(username);
|
|
280
|
-
const t0 = Date.now();
|
|
281
|
-
const seed = await argon2id({
|
|
282
|
-
password: new TextEncoder().encode(password.normalize('NFKC')),
|
|
283
|
-
salt,
|
|
284
|
-
iterations: 2,
|
|
285
|
-
parallelism: 1,
|
|
286
|
-
memorySize: 16 * 1024, // 16 MiB: demo-light, responsive in a tab
|
|
287
|
-
hashLength: SEED_LEN,
|
|
288
|
-
outputType: 'binary',
|
|
289
|
-
});
|
|
290
|
-
const deriveMs = Date.now() - t0;
|
|
291
|
-
|
|
292
|
-
const message = buildLoginMessage(username, aud, cid, nonce, iat, exp);
|
|
293
|
-
let publicKey: Uint8Array;
|
|
294
|
-
let signature: Uint8Array;
|
|
295
|
-
try {
|
|
296
|
-
const kp = ml_dsa44.keygen(seed);
|
|
297
|
-
publicKey = kp.publicKey;
|
|
298
|
-
try {
|
|
299
|
-
signature = ml_dsa44.sign(message, kp.secretKey, {
|
|
300
|
-
context: new TextEncoder().encode(LOGIN_CONTEXT),
|
|
301
|
-
});
|
|
302
|
-
} finally {
|
|
303
|
-
wipe(kp.secretKey);
|
|
304
|
-
}
|
|
305
|
-
} finally {
|
|
306
|
-
wipe(seed);
|
|
324
|
+
if (res.readU8() !== 0) {
|
|
325
|
+
wipe(sharedSecret);
|
|
326
|
+
throw new Error('auth: login failed');
|
|
307
327
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
deriveMs,
|
|
323
|
-
};
|
|
328
|
+
const session = res.readBytes();
|
|
329
|
+
const serverConfirm = res.readBytes();
|
|
330
|
+
|
|
331
|
+
// 5. Mutual auth: derive the session key K = HMAC(sharedSecret, label || H(M)),
|
|
332
|
+
// then check the server's tag = HMAC(K, label || H(M)). Only a server that
|
|
333
|
+
// decapsulated correctly derives the same K, so a valid tag proves its
|
|
334
|
+
// identity. Verify before returning the session.
|
|
335
|
+
const transcriptHash = await sha256Bytes(message);
|
|
336
|
+
const sessionKey = await hmacSha256(sharedSecret, concatBytes(utf8(SESSION_KEY_LABEL), transcriptHash));
|
|
337
|
+
wipe(sharedSecret);
|
|
338
|
+
const expected = await hmacSha256(sessionKey, concatBytes(utf8(SERVER_CONFIRM_LABEL), transcriptHash));
|
|
339
|
+
if (!bytesEqual(expected, serverConfirm)) throw new Error('auth: server authentication failed');
|
|
340
|
+
|
|
341
|
+
return session; // session token
|
|
324
342
|
}
|
|
325
343
|
|
|
326
344
|
/** The client auth surface, grouped for `Auth.register` / `Auth.login` use. */
|
|
327
|
-
export const Auth = { register, login,
|
|
345
|
+
export const Auth = { register, login, buildLoginMessage, LOGIN_CONTEXT } as const;
|
package/src/client/index.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
export { mount } from './routing/mount.js';
|
|
12
12
|
export { Router } from './routing/Router.js';
|
|
13
|
-
export { Auth, register as authRegister, login as authLogin,
|
|
14
|
-
export type { KdfParams,
|
|
13
|
+
export { Auth, register as authRegister, login as authLogin, buildLoginMessage, LOGIN_CONTEXT } from './auth.js';
|
|
14
|
+
export type { KdfParams, AuthOptions } from './auth.js';
|
|
15
15
|
export { Link } from './navigation/Link.js';
|
|
16
16
|
export type { LinkProps } from './navigation/Link.js';
|
|
17
17
|
export { NavLink, matchActive } from './navigation/NavLink.js';
|
package/src/compiler/generate.ts
CHANGED
|
@@ -122,7 +122,7 @@ export const TOIL_SERVER_ENV_DTS =
|
|
|
122
122
|
`declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }\n` +
|
|
123
123
|
`declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }\n` +
|
|
124
124
|
`declare namespace TwoFactor { const DEFAULT_TTL_SECS: u64; const DEFAULT_DIGITS: i32; function setSecret(secret: Uint8Array): void; function issue(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorIssue; function send(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorChallenge; function verify(token: string, recipient: string, code: string): bool; }\n` +
|
|
125
|
-
`declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function getUser(): __ToilAuthUser | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n` +
|
|
125
|
+
`declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function getUser(): __ToilAuthUser | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64, ciphertext: Uint8Array, memKiB: u32, iterations: u32, parallelism: u32, serverKemKeyId: Uint8Array): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; const KEM_CIPHERTEXT_LEN: i32; const KEM_SECRET_KEY_LEN: i32; const KEM_PUBLIC_KEY_LEN: i32; const SHARED_SECRET_LEN: i32; const OPRF_ELEMENT_LEN: i32; const OPRF_SEED_LEN: i32; const SESSION_KEY_LABEL: string; const SERVER_CONFIRM_LABEL: string; function setOprfSeed(seed: Uint8Array): void; function setServerKemSecretKey(secretKey: Uint8Array): void; function setServerKemPublicKey(publicKey: Uint8Array): void; function serverKemKeyId(): Uint8Array; function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array; function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array; function sha256(data: Uint8Array): Uint8Array; function deriveSessionKey(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array; function serverConfirmTag(sessionKey: Uint8Array, transcriptHash: Uint8Array): Uint8Array; const REGISTER_CONTEXT: string; function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array; function verifyRegister(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n` +
|
|
126
126
|
`interface __ToilAuthUser {}\n`;
|
|
127
127
|
|
|
128
128
|
/**
|
package/src/devserver/crypto.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
import * as nodeCrypto from 'node:crypto';
|
|
19
19
|
|
|
20
20
|
import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
|
|
21
|
+
import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
|
|
22
|
+
import { ristretto255_oprf } from '@noble/curves/ed25519.js';
|
|
21
23
|
|
|
22
24
|
import type { MemoryRef } from './host.js';
|
|
23
25
|
|
|
@@ -234,6 +236,58 @@ export function buildCryptoImports(
|
|
|
234
236
|
return -1;
|
|
235
237
|
}
|
|
236
238
|
},
|
|
239
|
+
|
|
240
|
+
// ML-KEM-768 (FIPS 203) decapsulation for the mutual-auth + session-key
|
|
241
|
+
// layer. Mirrors the edge host (`mlkem_decapsulate_import.rs` / fips203):
|
|
242
|
+
// recover the 32-byte shared secret from the client's ciphertext using
|
|
243
|
+
// the server's static secret key, write it to `outPtr`, return 0 / neg.
|
|
244
|
+
// Backed by the same noble lib the client encapsulates with (dev == prod).
|
|
245
|
+
'crypto.mlkem_decapsulate': (
|
|
246
|
+
ctPtr: number, ctLen: number,
|
|
247
|
+
skPtr: number, skLen: number,
|
|
248
|
+
outPtr: number,
|
|
249
|
+
): number => {
|
|
250
|
+
if (ctLen !== 1088 || skLen !== 2400) return -4;
|
|
251
|
+
try {
|
|
252
|
+
const ct = new Uint8Array(readBytes(ref, ctPtr, ctLen));
|
|
253
|
+
const sk = new Uint8Array(readBytes(ref, skPtr, skLen));
|
|
254
|
+
const ss = ml_kem768.decapsulate(ct, sk); // 32 bytes; implicit rejection on bad ct
|
|
255
|
+
writeBytes(ref, outPtr, Buffer.from(ss));
|
|
256
|
+
return 0;
|
|
257
|
+
} catch {
|
|
258
|
+
return -5;
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
// RFC 9497 OPRF (mode 0x00, ristretto255-SHA512) server evaluation for
|
|
263
|
+
// the keyed-salt OPRF. Mirrors the edge host
|
|
264
|
+
// (`voprf_evaluate_import.rs` / the `voprf` crate): derive the per-user
|
|
265
|
+
// key from (seed, info=username) and blind-evaluate the client's blinded
|
|
266
|
+
// element, writing the 32-byte evaluated element to `outPtr`. Backed by
|
|
267
|
+
// `@noble/curves` ristretto255_oprf, which matches the edge byte-for-byte
|
|
268
|
+
// (both RFC 9497), so dev == prod.
|
|
269
|
+
'crypto.voprf_evaluate': (
|
|
270
|
+
seedPtr: number, seedLen: number,
|
|
271
|
+
infoPtr: number, infoLen: number,
|
|
272
|
+
blindedPtr: number, blindedLen: number,
|
|
273
|
+
outPtr: number,
|
|
274
|
+
): number => {
|
|
275
|
+
// seedLen MUST be exactly 32 (RFC 9497 Ns; noble deriveKeyPair rejects
|
|
276
|
+
// other lengths) -- matching the edge so dev and prod never diverge.
|
|
277
|
+
if (blindedLen !== 32 || seedLen !== 32 || infoLen > 512) return -4;
|
|
278
|
+
try {
|
|
279
|
+
const seed = new Uint8Array(readBytes(ref, seedPtr, seedLen));
|
|
280
|
+
const info = new Uint8Array(readBytes(ref, infoPtr, infoLen));
|
|
281
|
+
const blinded = new Uint8Array(readBytes(ref, blindedPtr, blindedLen));
|
|
282
|
+
const oprf = ristretto255_oprf.oprf;
|
|
283
|
+
const kp = oprf.deriveKeyPair(seed, info);
|
|
284
|
+
const evaluated = oprf.blindEvaluate(kp.secretKey, blinded); // 32-byte element
|
|
285
|
+
writeBytes(ref, outPtr, Buffer.from(evaluated));
|
|
286
|
+
return 0;
|
|
287
|
+
} catch {
|
|
288
|
+
return -5;
|
|
289
|
+
}
|
|
290
|
+
},
|
|
237
291
|
};
|
|
238
292
|
}
|
|
239
293
|
|
package/src/devserver/host.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { buildCryptoImports, freshCryptoState, type CryptoState } from './crypto.js';
|
|
21
|
+
import { buildKvImports } from './kv.js';
|
|
21
22
|
import { EmailStatus, getEmailService } from './email/index.js';
|
|
22
23
|
import { parseEmailBlob } from './email/wire.js';
|
|
23
24
|
import { devEnvGet, devEnvGetSecure } from './env.js';
|
|
@@ -268,6 +269,11 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
|
|
|
268
269
|
// Web Crypto host functions (`env.crypto.*`), backed by Node's
|
|
269
270
|
// `crypto`. The dev server skips metering, so these charge nothing.
|
|
270
271
|
...buildCryptoImports(ref, state.crypto),
|
|
272
|
+
|
|
273
|
+
// DEV-ONLY persistent KV (`env.kv.*`). REMOVE LATER — scaffolding so
|
|
274
|
+
// the auth example's register/login chain spans requests under
|
|
275
|
+
// `toiljs dev`; not present on the production edge (see ./kv.ts).
|
|
276
|
+
...buildKvImports(ref),
|
|
271
277
|
},
|
|
272
278
|
};
|
|
273
279
|
}
|