toiljs 0.0.52 → 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 +10 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +2 -13
- package/build/client/auth.js +22 -86
- 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/docs/auth-todo.md +149 -0
- package/docs/auth.md +234 -173
- package/examples/basic/client/routes/pq.tsx +12 -6
- package/examples/basic/server/core/AppHandler.ts +24 -3
- package/examples/basic/server/main.ts +0 -1
- package/examples/basic/server/routes/Auth.ts +56 -25
- package/examples/basic/server/routes/Session.ts +5 -2
- package/package.json +1 -1
- package/server/globals/auth.ts +93 -55
- package/src/client/auth.ts +47 -157
- package/src/client/index.ts +2 -2
- package/src/compiler/generate.ts +1 -1
- package/src/devserver/crypto.ts +1 -1
- package/src/devserver/kv.ts +9 -12
- package/test/pqauth-e2e.test.ts +49 -4
- package/examples/basic/server/routes/PqDemo.ts +0 -127
package/src/client/auth.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* JSON, byte-identical to the server's `AuthService.buildLoginMessage`.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { argon2id,
|
|
14
|
+
import { argon2id, createSHA256, createHMAC } from 'hash-wasm';
|
|
15
15
|
import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
|
|
16
16
|
import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
|
|
17
17
|
import { ristretto255_oprf } from '@noble/curves/ed25519.js';
|
|
@@ -22,7 +22,9 @@ import { DataReader, DataWriter } from 'toiljs/io';
|
|
|
22
22
|
export const LOGIN_CONTEXT = 'qauth:login:v1';
|
|
23
23
|
/** Registration proof-of-possession context (binds a sig to "register"). */
|
|
24
24
|
export const REGISTER_CONTEXT = 'qauth:register:v1';
|
|
25
|
-
/** Domain
|
|
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';
|
|
26
28
|
export const SERVER_CONFIRM_LABEL = 'toil-server-confirm-v1';
|
|
27
29
|
|
|
28
30
|
/** ML-KEM-768 sizes (FIPS 203). */
|
|
@@ -98,6 +100,16 @@ async function sha256Bytes(data: Uint8Array): Promise<Uint8Array> {
|
|
|
98
100
|
return h.digest('binary') as unknown as Uint8Array;
|
|
99
101
|
}
|
|
100
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
|
+
|
|
101
113
|
function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
|
102
114
|
let n = 0;
|
|
103
115
|
for (const p of parts) n += p.length;
|
|
@@ -118,8 +130,11 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
|
118
130
|
return diff === 0;
|
|
119
131
|
}
|
|
120
132
|
|
|
121
|
-
/** The canonical login message `M
|
|
122
|
-
* `AuthService.buildLoginMessage
|
|
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). */
|
|
123
138
|
export function buildLoginMessage(
|
|
124
139
|
sub: string,
|
|
125
140
|
aud: string,
|
|
@@ -127,32 +142,14 @@ export function buildLoginMessage(
|
|
|
127
142
|
nonce: Uint8Array,
|
|
128
143
|
iat: bigint,
|
|
129
144
|
exp: bigint,
|
|
130
|
-
): Uint8Array {
|
|
131
|
-
return new DataWriter()
|
|
132
|
-
.writeU8(1)
|
|
133
|
-
.writeString(sub)
|
|
134
|
-
.writeString(aud)
|
|
135
|
-
.writeBytes(cid)
|
|
136
|
-
.writeBytes(nonce)
|
|
137
|
-
.writeU64(iat)
|
|
138
|
-
.writeU64(exp)
|
|
139
|
-
.toBytes();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Login message `M` v2: the v1 fields plus the ML-KEM ciphertext, so the
|
|
143
|
-
* ML-DSA signature binds the key encapsulation. Byte-identical to the server's
|
|
144
|
-
* `AuthService.buildLoginMessageV2`. */
|
|
145
|
-
export function buildLoginMessageV2(
|
|
146
|
-
sub: string,
|
|
147
|
-
aud: string,
|
|
148
|
-
cid: Uint8Array,
|
|
149
|
-
nonce: Uint8Array,
|
|
150
|
-
iat: bigint,
|
|
151
|
-
exp: bigint,
|
|
152
145
|
ciphertext: Uint8Array,
|
|
146
|
+
memKiB: number,
|
|
147
|
+
iterations: number,
|
|
148
|
+
parallelism: number,
|
|
149
|
+
serverKemKeyId: Uint8Array,
|
|
153
150
|
): Uint8Array {
|
|
154
151
|
return new DataWriter()
|
|
155
|
-
.writeU8(
|
|
152
|
+
.writeU8(1)
|
|
156
153
|
.writeString(sub)
|
|
157
154
|
.writeString(aud)
|
|
158
155
|
.writeBytes(cid)
|
|
@@ -160,6 +157,10 @@ export function buildLoginMessageV2(
|
|
|
160
157
|
.writeU64(iat)
|
|
161
158
|
.writeU64(exp)
|
|
162
159
|
.writeBytes(ciphertext)
|
|
160
|
+
.writeU32(memKiB)
|
|
161
|
+
.writeU32(iterations)
|
|
162
|
+
.writeU32(parallelism)
|
|
163
|
+
.writeBytes(serverKemKeyId)
|
|
163
164
|
.toBytes();
|
|
164
165
|
}
|
|
165
166
|
|
|
@@ -170,7 +171,6 @@ export function buildRegisterMessage(username: string, publicKey: Uint8Array): U
|
|
|
170
171
|
return new DataWriter().writeU8(1).writeString(username).writeBytes(publicKey).toBytes();
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
// ---- wire codecs (the example `Auth` @rest controller mirrors these) -------
|
|
174
174
|
|
|
175
175
|
function decodeKdf(r: DataReader): KdfParams {
|
|
176
176
|
return {
|
|
@@ -199,7 +199,7 @@ export interface AuthOptions {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
|
-
* Register a new account
|
|
202
|
+
* Register a new account. The password never leaves the browser:
|
|
203
203
|
* it is blinded and run through the server-keyed OPRF, the OPRF output is
|
|
204
204
|
* stretched with Argon2id into an ML-DSA-44 keypair, and ONLY the public key
|
|
205
205
|
* (plus a proof-of-possession signature) is submitted. Throws on failure.
|
|
@@ -249,11 +249,13 @@ export async function register(username: string, password: string, opts: AuthOpt
|
|
|
249
249
|
'/register/finish',
|
|
250
250
|
new DataWriter().writeString(username).writeBytes(publicKey).writeBytes(regProof).toBytes(),
|
|
251
251
|
);
|
|
252
|
-
|
|
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');
|
|
253
255
|
}
|
|
254
256
|
|
|
255
257
|
/**
|
|
256
|
-
* Log in (
|
|
258
|
+
* Log in (challenge-response with ML-KEM mutual auth). Steps:
|
|
257
259
|
* 1. Blind the password; `login/start` returns the challenge + the OPRF
|
|
258
260
|
* evaluation (a fully-formed response even for unknown users -> no oracle).
|
|
259
261
|
* 2. Finalize the OPRF -> keyed salt -> seed -> ML-DSA keypair.
|
|
@@ -292,9 +294,14 @@ export async function login(username: string, password: string, opts: AuthOption
|
|
|
292
294
|
const oprfOutput = oprf.finalize(pw, blind, evaluated);
|
|
293
295
|
const seed = await deriveSeed(oprfOutput, kdf);
|
|
294
296
|
|
|
295
|
-
// 3. Encapsulate to the pinned server KEM key
|
|
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.
|
|
296
299
|
const { cipherText, sharedSecret } = ml_kem768.encapsulate(SERVER_KEM_PUBLIC_KEY);
|
|
297
|
-
const
|
|
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
|
+
);
|
|
298
305
|
let signature: Uint8Array;
|
|
299
306
|
try {
|
|
300
307
|
const kp = ml_dsa44.keygen(seed);
|
|
@@ -321,135 +328,18 @@ export async function login(username: string, password: string, opts: AuthOption
|
|
|
321
328
|
const session = res.readBytes();
|
|
322
329
|
const serverConfirm = res.readBytes();
|
|
323
330
|
|
|
324
|
-
// 5. Mutual auth:
|
|
325
|
-
//
|
|
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.
|
|
326
335
|
const transcriptHash = await sha256Bytes(message);
|
|
327
|
-
const
|
|
328
|
-
concatBytes(utf8(SERVER_CONFIRM_LABEL), sharedSecret, transcriptHash),
|
|
329
|
-
);
|
|
336
|
+
const sessionKey = await hmacSha256(sharedSecret, concatBytes(utf8(SESSION_KEY_LABEL), transcriptHash));
|
|
330
337
|
wipe(sharedSecret);
|
|
338
|
+
const expected = await hmacSha256(sessionKey, concatBytes(utf8(SERVER_CONFIRM_LABEL), transcriptHash));
|
|
331
339
|
if (!bytesEqual(expected, serverConfirm)) throw new Error('auth: server authentication failed');
|
|
332
340
|
|
|
333
341
|
return session; // session token
|
|
334
342
|
}
|
|
335
343
|
|
|
336
|
-
/** Lowercase hex of `bytes`. */
|
|
337
|
-
function toHex(bytes: Uint8Array): string {
|
|
338
|
-
let s = '';
|
|
339
|
-
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
|
340
|
-
return s;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/** The signed identity proof produced by {@link proveIdentity}. */
|
|
344
|
-
export interface IdentityProof {
|
|
345
|
-
/** The wire envelope to POST to `/pq/verify`: `str(sub) str(token)
|
|
346
|
-
* bytes(publicKey) bytes(signature)`, where `token` is the edge's
|
|
347
|
-
* HMAC-signed challenge. The server re-opens the token, rebuilds the login
|
|
348
|
-
* message from the values inside it, and `AuthService.verifyLogin`s it. */
|
|
349
|
-
readonly envelope: Uint8Array;
|
|
350
|
-
/** First bytes of the 1312-byte ML-DSA-44 public key, for display. */
|
|
351
|
-
readonly publicKeyHex: string;
|
|
352
|
-
/** First bytes of the SERVER-issued nonce that was signed, for display. */
|
|
353
|
-
readonly nonceHex: string;
|
|
354
|
-
/** Signature length (always 2420 for ML-DSA-44), for display. */
|
|
355
|
-
readonly signatureLen: number;
|
|
356
|
-
/** Argon2id wall-clock spent deriving the keypair, ms (for display). */
|
|
357
|
-
readonly deriveMs: number;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Deterministic 16-byte Argon2id salt for the demo, so the same
|
|
361
|
-
* username + password always maps to the same identity (keypair).
|
|
362
|
-
* Uses hash-wasm's SHA-256 (pure WebAssembly), not `crypto.subtle`, so it
|
|
363
|
-
* works in an insecure context (plain HTTP), where `crypto.subtle` is
|
|
364
|
-
* undefined. */
|
|
365
|
-
async function demoSalt(username: string): Promise<Uint8Array> {
|
|
366
|
-
const hex = await sha256('pq-demo|' + username);
|
|
367
|
-
const out = new Uint8Array(16);
|
|
368
|
-
for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
369
|
-
return out;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* DEMO helper: run the full post-quantum challenge-response in the browser.
|
|
374
|
-
* Fetches a SERVER-issued challenge (`GET {baseUrl}/challenge`), stretches the
|
|
375
|
-
* password with Argon2id into an ML-DSA-44 keypair, signs the login message
|
|
376
|
-
* built from the SERVER's nonce/cid/iat/exp, and returns the wire envelope the
|
|
377
|
-
* edge verifies (`AuthService.verifyLogin`). The secret key and seed are wiped
|
|
378
|
-
* before returning; only the public key + signature leave the tab.
|
|
379
|
-
*
|
|
380
|
-
* The nonce is server-chosen and tamper-proof (the challenge token is
|
|
381
|
-
* HMAC-signed by the edge), so a client cannot pre-sign or substitute its own.
|
|
382
|
-
* It is still NOT the full production login -- there is no single-use consume, so
|
|
383
|
-
* within the challenge TTL a captured proof could be replayed; that needs an
|
|
384
|
-
* atomic store (see {@link login} and server/routes/Auth.ts). Demo-light
|
|
385
|
-
* Argon2id params (16 MiB / 2 passes) keep it responsive in a tab; a real
|
|
386
|
-
* deployment uses >= 256 MiB.
|
|
387
|
-
*/
|
|
388
|
-
export async function proveIdentity(
|
|
389
|
-
username: string,
|
|
390
|
-
password: string,
|
|
391
|
-
opts: { baseUrl?: string } = {},
|
|
392
|
-
): Promise<IdentityProof> {
|
|
393
|
-
const baseUrl = opts.baseUrl ?? '/pq';
|
|
394
|
-
|
|
395
|
-
// 1. Server-issued challenge: aud, cid, nonce, iat, exp, and the signed token.
|
|
396
|
-
const cres = await fetch(baseUrl + '/challenge', { credentials: 'same-origin' });
|
|
397
|
-
if (!cres.ok) throw new Error('pq: challenge request failed');
|
|
398
|
-
const cr = new DataReader(new Uint8Array(await cres.arrayBuffer()));
|
|
399
|
-
const aud = cr.readString();
|
|
400
|
-
const cid = cr.readBytes();
|
|
401
|
-
const nonce = cr.readBytes();
|
|
402
|
-
const iat = cr.readU64();
|
|
403
|
-
const exp = cr.readU64();
|
|
404
|
-
const token = cr.readString();
|
|
405
|
-
|
|
406
|
-
// 2. Derive the keypair and sign the message built from the SERVER's values.
|
|
407
|
-
const salt = await demoSalt(username);
|
|
408
|
-
const t0 = Date.now();
|
|
409
|
-
const seed = await argon2id({
|
|
410
|
-
password: new TextEncoder().encode(password.normalize('NFKC')),
|
|
411
|
-
salt,
|
|
412
|
-
iterations: 2,
|
|
413
|
-
parallelism: 1,
|
|
414
|
-
memorySize: 16 * 1024, // 16 MiB: demo-light, responsive in a tab
|
|
415
|
-
hashLength: SEED_LEN,
|
|
416
|
-
outputType: 'binary',
|
|
417
|
-
});
|
|
418
|
-
const deriveMs = Date.now() - t0;
|
|
419
|
-
|
|
420
|
-
const message = buildLoginMessage(username, aud, cid, nonce, iat, exp);
|
|
421
|
-
let publicKey: Uint8Array;
|
|
422
|
-
let signature: Uint8Array;
|
|
423
|
-
try {
|
|
424
|
-
const kp = ml_dsa44.keygen(seed);
|
|
425
|
-
publicKey = kp.publicKey;
|
|
426
|
-
try {
|
|
427
|
-
signature = ml_dsa44.sign(message, kp.secretKey, {
|
|
428
|
-
context: new TextEncoder().encode(LOGIN_CONTEXT),
|
|
429
|
-
});
|
|
430
|
-
} finally {
|
|
431
|
-
wipe(kp.secretKey);
|
|
432
|
-
}
|
|
433
|
-
} finally {
|
|
434
|
-
wipe(seed);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// 3. Envelope: sub + the server's token + the public key + the signature.
|
|
438
|
-
const envelope = new DataWriter()
|
|
439
|
-
.writeString(username)
|
|
440
|
-
.writeString(token)
|
|
441
|
-
.writeBytes(publicKey)
|
|
442
|
-
.writeBytes(signature)
|
|
443
|
-
.toBytes();
|
|
444
|
-
|
|
445
|
-
return {
|
|
446
|
-
envelope,
|
|
447
|
-
publicKeyHex: toHex(publicKey.slice(0, 16)),
|
|
448
|
-
nonceHex: toHex(nonce.slice(0, 16)),
|
|
449
|
-
signatureLen: signature.length,
|
|
450
|
-
deriveMs,
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
|
|
454
344
|
/** The client auth surface, grouped for `Auth.register` / `Auth.login` use. */
|
|
455
|
-
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, AuthOptions
|
|
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; 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 SERVER_CONFIRM_LABEL: string; function setOprfSeed(seed: Uint8Array): void; function setServerKemSecretKey(secretKey: Uint8Array): void; function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array; function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array; function sha256(data: Uint8Array): Uint8Array; function
|
|
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
|
@@ -260,7 +260,7 @@ export function buildCryptoImports(
|
|
|
260
260
|
},
|
|
261
261
|
|
|
262
262
|
// RFC 9497 OPRF (mode 0x00, ristretto255-SHA512) server evaluation for
|
|
263
|
-
// the
|
|
263
|
+
// the keyed-salt OPRF. Mirrors the edge host
|
|
264
264
|
// (`voprf_evaluate_import.rs` / the `voprf` crate): derive the per-user
|
|
265
265
|
// key from (seed, info=username) and blind-evaluate the client's blinded
|
|
266
266
|
// element, writing the 32-byte evaluated element to `outPtr`. Backed by
|
package/src/devserver/kv.ts
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DEV-ONLY persistent key-value store host imports (`env::kv.*`).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* store. This module is a single-process `Map` standing in for that store.
|
|
4
|
+
* REMOVE LATER. This exists ONLY so the post-quantum auth example can run the
|
|
5
|
+
* full register -> login chain end-to-end under `toiljs dev`. A tenant's wasm
|
|
6
|
+
* linear memory is wiped after every request, so account records and login
|
|
7
|
+
* challenges cannot live in the guest across the two round trips; they need an
|
|
8
|
+
* external store. This module is a single-process `Map` standing in for that.
|
|
10
9
|
*
|
|
11
10
|
* It is intentionally NOT registered on the production Rust edge
|
|
12
11
|
* (`toil-backend` `HOST_IMPORTS`), so a module using `kv.*` will not instantiate
|
|
13
|
-
* there.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* restart.
|
|
18
|
-
* ========================================================================
|
|
12
|
+
* there. Once toildb is implemented, move the example's account + challenge
|
|
13
|
+
* stores onto toildb (which provides the atomic fetch-and-delete the challenge
|
|
14
|
+
* consume needs) and delete this whole module. DO NOT ship this `Map` as a
|
|
15
|
+
* production storage path: it is single-instance, unbounded, and lost on restart.
|
|
19
16
|
*
|
|
20
17
|
* Wire ABI (mirrors the crypto imports' caller-allocated-buffer convention):
|
|
21
18
|
* kv.put(keyPtr, keyLen, valPtr, valLen) -> void
|
package/test/pqauth-e2e.test.ts
CHANGED
|
@@ -40,20 +40,32 @@ function loadModule(): WasmServerModule {
|
|
|
40
40
|
/** Route the client's `fetch(path, {body})` into the dev wasm dispatcher. */
|
|
41
41
|
function installFetchShim(m: WasmServerModule): () => void {
|
|
42
42
|
const original = globalThis.fetch;
|
|
43
|
+
const jar = new Map<string, string>();
|
|
43
44
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
44
45
|
const url = typeof input === 'string' ? input : input.toString();
|
|
45
46
|
const pathname = new URL(url, 'http://localhost').pathname;
|
|
46
47
|
const bodyBytes =
|
|
47
48
|
init?.body == null ? new Uint8Array(0) : new Uint8Array(init.body as ArrayBuffer);
|
|
49
|
+
const headers: [string, string][] = [
|
|
50
|
+
['host', 'localhost:3000'],
|
|
51
|
+
['content-type', 'application/octet-stream'],
|
|
52
|
+
];
|
|
53
|
+
if (jar.size > 0)
|
|
54
|
+
headers.push(['cookie', [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ')]);
|
|
48
55
|
const r = m.dispatch({
|
|
49
56
|
method: (init?.method ?? 'GET') as 'GET' | 'POST',
|
|
50
57
|
path: pathname,
|
|
51
|
-
headers
|
|
52
|
-
['host', 'localhost:3000'],
|
|
53
|
-
['content-type', 'application/octet-stream'],
|
|
54
|
-
],
|
|
58
|
+
headers,
|
|
55
59
|
body: bodyBytes,
|
|
56
60
|
});
|
|
61
|
+
// Fold any Set-Cookie (`name=value; Path=/; ...`) into the jar so the next
|
|
62
|
+
// request replays it, exactly like a browser.
|
|
63
|
+
for (const [name, value] of r.headers) {
|
|
64
|
+
if (name.toLowerCase() !== 'set-cookie') continue;
|
|
65
|
+
const pair = value.split(';', 1)[0];
|
|
66
|
+
const eq = pair.indexOf('=');
|
|
67
|
+
if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
68
|
+
}
|
|
57
69
|
const ab = r.body.buffer.slice(r.body.byteOffset, r.body.byteOffset + r.body.byteLength);
|
|
58
70
|
return {
|
|
59
71
|
ok: r.status >= 200 && r.status < 300,
|
|
@@ -86,6 +98,26 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
|
|
|
86
98
|
// verified against the client's own shared secret.
|
|
87
99
|
const session = await Auth.login('ada', 'correct horse battery staple');
|
|
88
100
|
expect(session.length).toBeGreaterThan(0);
|
|
101
|
+
|
|
102
|
+
// Regression guard: the freshly minted session cookie (captured by the
|
|
103
|
+
// shim jar) must be ACCEPTED by the `@auth`-gated `/session/me`, which
|
|
104
|
+
// runs in a separate fresh wasm instance from `login/finish`. The bug
|
|
105
|
+
// was that login configured a different HMAC secret than the verify
|
|
106
|
+
// path used, so the MAC failed and `/session/me` returned 401.
|
|
107
|
+
const meRes = await fetch('/session/me');
|
|
108
|
+
expect(meRes.status).toBe(200);
|
|
109
|
+
const me = new DataReader(new Uint8Array(await meRes.arrayBuffer()));
|
|
110
|
+
expect(me.readString()).toBe('ada');
|
|
111
|
+
expect(me.readBool()).toBe(false); // 'ada' is not 'root' -> not admin
|
|
112
|
+
},
|
|
113
|
+
60_000,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
it(
|
|
117
|
+
'rejects /session/me with no session cookie (the @auth gate holds)',
|
|
118
|
+
async () => {
|
|
119
|
+
const meRes = await fetch('/session/me');
|
|
120
|
+
expect(meRes.status).toBe(401);
|
|
89
121
|
},
|
|
90
122
|
60_000,
|
|
91
123
|
);
|
|
@@ -106,6 +138,19 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
|
|
|
106
138
|
},
|
|
107
139
|
60_000,
|
|
108
140
|
);
|
|
141
|
+
|
|
142
|
+
it(
|
|
143
|
+
'rejects a duplicate registration with a clear (not generic) message',
|
|
144
|
+
async () => {
|
|
145
|
+
await Auth.register('dupe', 'correct horse battery staple');
|
|
146
|
+
// Second registration of the same username must say so explicitly,
|
|
147
|
+
// not return the generic "request failed".
|
|
148
|
+
await expect(Auth.register('dupe', 'correct horse battery staple')).rejects.toThrow(
|
|
149
|
+
/already registered/,
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
60_000,
|
|
153
|
+
);
|
|
109
154
|
});
|
|
110
155
|
|
|
111
156
|
// Lower-level wire checks that don't need the heavy Argon2id derivation.
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { Response, RouteContext, SecureCookies, base64UrlEncode, base64UrlDecode } from 'toiljs/server/runtime';
|
|
2
|
-
import { DataReader, DataWriter } from 'data';
|
|
3
|
-
|
|
4
|
-
import { encodeSessionUser } from './Session';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Post-quantum identity demo (server half), challenge-response.
|
|
8
|
-
*
|
|
9
|
-
* 1. GET /pq/challenge -> the edge mints a fresh nonce + cid + iat/exp and
|
|
10
|
-
* returns them PLUS an HMAC-signed `token` over those values. The token is
|
|
11
|
-
* the server-issued challenge: signed with a server-only key, it proves
|
|
12
|
-
* "the edge issued exactly this" WITHOUT any cross-request storage (the
|
|
13
|
-
* guest's memory is wiped every request).
|
|
14
|
-
* 2. POST /pq/verify -> the client signs the login message built from the
|
|
15
|
-
* SERVER's nonce/cid/iat/exp (ML-DSA-44, derived from the password) and
|
|
16
|
-
* returns {sub, token, publicKey, signature}. The edge re-opens the token
|
|
17
|
-
* (rejecting a forged or expired one), rebuilds the message from the values
|
|
18
|
-
* INSIDE the token (never client-echoed), and verifies the signature via
|
|
19
|
-
* `crypto.mldsa_verify` (`AuthService.verifyLogin`).
|
|
20
|
-
*
|
|
21
|
-
* The nonce is server-chosen and tamper-proof, and the challenge is time-bound,
|
|
22
|
-
* so a client cannot pre-sign or substitute its own nonce. What this stateless
|
|
23
|
-
* form does NOT have is single-use: within the TTL a captured {token, signature}
|
|
24
|
-
* could be replayed, because that needs an atomic consume against a store (Redis
|
|
25
|
-
* GETDEL / SQL DELETE RETURNING). The production login in server/routes/Auth.ts
|
|
26
|
-
* does exactly that; see docs/auth.md. Pairs with client/routes/pq.tsx.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
const AUD = 'pq-demo'; // this demo's audience id (server config; never client-echoed)
|
|
30
|
-
const CHALLENGE_TTL_SECS: u64 = 120;
|
|
31
|
-
|
|
32
|
-
/** Server-only key for signing challenge tokens (demo constant; a real
|
|
33
|
-
* deployment uses a per-deployment secret, like the session secret). */
|
|
34
|
-
function challengeKey(): Uint8Array {
|
|
35
|
-
return crypto.sha256Text('toil-pq-demo-challenge-key-v1');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function randomBytes(n: i32): Uint8Array {
|
|
39
|
-
const b = new Uint8Array(n);
|
|
40
|
-
crypto.getRandomValues(b);
|
|
41
|
-
return b;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
@rest('pq')
|
|
45
|
-
class PqDemo {
|
|
46
|
-
/** GET /pq/challenge
|
|
47
|
-
* resp: str(aud) bytes(cid) bytes(nonce) u64(iat) u64(exp) str(token) */
|
|
48
|
-
@get('/challenge')
|
|
49
|
-
public challenge(_ctx: RouteContext): Response {
|
|
50
|
-
const cid = randomBytes(16);
|
|
51
|
-
const nonce = randomBytes(32);
|
|
52
|
-
const iat = Time.nowSeconds();
|
|
53
|
-
const exp = iat + CHALLENGE_TTL_SECS;
|
|
54
|
-
|
|
55
|
-
// Sign (iat, exp, cid, nonce) so /verify can confirm the edge issued
|
|
56
|
-
// this exact challenge with no stored state.
|
|
57
|
-
const blob = new DataWriter()
|
|
58
|
-
.writeU64(iat)
|
|
59
|
-
.writeU64(exp)
|
|
60
|
-
.writeBytes(cid)
|
|
61
|
-
.writeBytes(nonce)
|
|
62
|
-
.toBytes();
|
|
63
|
-
const token = SecureCookies.signed(challengeKey()).sign('pqchal', base64UrlEncode(blob));
|
|
64
|
-
|
|
65
|
-
const w = new DataWriter();
|
|
66
|
-
w.writeString(AUD);
|
|
67
|
-
w.writeBytes(cid);
|
|
68
|
-
w.writeBytes(nonce);
|
|
69
|
-
w.writeU64(iat);
|
|
70
|
-
w.writeU64(exp);
|
|
71
|
-
w.writeString(token);
|
|
72
|
-
return Response.bytes(w.toBytes());
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** POST /pq/verify
|
|
76
|
-
* body: str(sub) str(token) bytes(publicKey 1312) bytes(signature 2420)
|
|
77
|
-
* resp: text VALID / INVALID */
|
|
78
|
-
@post('/verify')
|
|
79
|
-
public verify(ctx: RouteContext): Response {
|
|
80
|
-
const r = new DataReader(ctx.request.body);
|
|
81
|
-
const sub = r.readString();
|
|
82
|
-
const token = r.readString();
|
|
83
|
-
const pk = r.readBytes();
|
|
84
|
-
const sig = r.readBytes();
|
|
85
|
-
if (!r.ok) return Response.text('malformed envelope\n', 400);
|
|
86
|
-
|
|
87
|
-
// 1. Re-open the challenge token: must be server-issued + untampered.
|
|
88
|
-
const blobB64 = SecureCookies.signed(challengeKey()).unsign('pqchal', token);
|
|
89
|
-
if (blobB64 == null) return Response.text('INVALID: forged or unsigned challenge\n', 401);
|
|
90
|
-
const blob = base64UrlDecode(blobB64);
|
|
91
|
-
if (blob == null) return Response.text('INVALID: malformed challenge\n', 401);
|
|
92
|
-
const br = new DataReader(blob);
|
|
93
|
-
const iat = br.readU64();
|
|
94
|
-
const exp = br.readU64();
|
|
95
|
-
const cid = br.readBytes();
|
|
96
|
-
const nonce = br.readBytes();
|
|
97
|
-
if (!br.ok) return Response.text('INVALID: malformed challenge\n', 401);
|
|
98
|
-
if (Time.nowSeconds() >= exp) return Response.text('INVALID: challenge expired\n', 401);
|
|
99
|
-
|
|
100
|
-
// TODO(db): single-use consume is NOT implemented yet (no KV/DB host
|
|
101
|
-
// binding available). The real fix is an ATOMIC consume of `cid` here --
|
|
102
|
-
// Redis GETDEL / SQL DELETE ... RETURNING -- so a given challenge verifies
|
|
103
|
-
// at most once. Until that exists, this token is replayable within its
|
|
104
|
-
// TTL: a captured {token, signature} re-verifies until `exp`. The
|
|
105
|
-
// production login (server/routes/Auth.ts) shows the atomic-consume shape.
|
|
106
|
-
|
|
107
|
-
// 2. Rebuild the message from the SERVER's values (inside the token,
|
|
108
|
-
// never client-echoed) and verify the ML-DSA-44 signature.
|
|
109
|
-
const message = AuthService.buildLoginMessage(sub, AUD, cid, nonce, iat, exp);
|
|
110
|
-
if (!AuthService.verifyLogin(pk, message, sig)) {
|
|
111
|
-
return Response.text('INVALID: signature did not verify\n', 401);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// 3. FULL AUTH: a valid post-quantum proof logs the user in. Mint the
|
|
115
|
-
// signed session for the proven `sub` via the @user codec, plus the
|
|
116
|
-
// readable companion. Every `@auth` route now recognises this user
|
|
117
|
-
// and `AuthService.getUser()` returns `{ username: sub, ... }`.
|
|
118
|
-
const userData = encodeSessionUser(sub);
|
|
119
|
-
const resp = Response.text(
|
|
120
|
-
'VALID: ML-DSA-44 (FIPS 204) verified; session established (@auth ready)\n',
|
|
121
|
-
200,
|
|
122
|
-
);
|
|
123
|
-
resp.setCookie(AuthService.mintSession(userData, 3600));
|
|
124
|
-
resp.setCookie(AuthService.userCookie(userData, 3600));
|
|
125
|
-
return resp;
|
|
126
|
-
}
|
|
127
|
-
}
|