toiljs 0.0.51 → 0.0.52
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 +9 -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 +8 -8
- package/build/client/auth.js +105 -24
- package/build/client/index.d.ts +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/examples/basic/client/routes/pq.tsx +67 -104
- package/examples/basic/server/routes/Auth.ts +274 -100
- package/package.json +2 -1
- package/server/globals/auth.ts +215 -0
- package/src/cli/diagnostics.ts +22 -0
- package/src/cli/doctor.ts +2 -0
- package/src/client/auth.ts +179 -51
- package/src/client/index.ts +1 -1
- 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 +96 -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 +162 -0
package/server/globals/auth.ts
CHANGED
|
@@ -37,6 +37,38 @@ declare function __toilMldsaVerify(
|
|
|
37
37
|
ctxLen: i32,
|
|
38
38
|
): i32;
|
|
39
39
|
|
|
40
|
+
// Host import: ML-KEM-768 (FIPS 203) decapsulation. Recovers the 32-byte shared
|
|
41
|
+
// secret from the client's ciphertext using the server static secret key,
|
|
42
|
+
// written to `outPtr`. Returns 0 on success, negative on error. The secret key
|
|
43
|
+
// is the server's own identity key (NOT password-derived), configured at
|
|
44
|
+
// startup and passed per call; the host never stores it.
|
|
45
|
+
// @ts-ignore: decorator
|
|
46
|
+
@external('env', 'crypto.mlkem_decapsulate')
|
|
47
|
+
declare function __toilMlkemDecapsulate(
|
|
48
|
+
ctPtr: usize,
|
|
49
|
+
ctLen: i32,
|
|
50
|
+
skPtr: usize,
|
|
51
|
+
skLen: i32,
|
|
52
|
+
outPtr: usize,
|
|
53
|
+
): i32;
|
|
54
|
+
|
|
55
|
+
// Host import: RFC 9497 OPRF (mode 0x00, ristretto255-SHA512) server evaluation.
|
|
56
|
+
// Derives the per-user key from (seed, info=username) and blind-evaluates the
|
|
57
|
+
// client's blinded element, writing the 32-byte evaluated element to `outPtr`.
|
|
58
|
+
// Returns 0 on success, negative on error. `seed` is the server's secret OPRF
|
|
59
|
+
// master seed, configured at startup and passed per call.
|
|
60
|
+
// @ts-ignore: decorator
|
|
61
|
+
@external('env', 'crypto.voprf_evaluate')
|
|
62
|
+
declare function __toilVoprfEvaluate(
|
|
63
|
+
seedPtr: usize,
|
|
64
|
+
seedLen: i32,
|
|
65
|
+
infoPtr: usize,
|
|
66
|
+
infoLen: i32,
|
|
67
|
+
blindedPtr: usize,
|
|
68
|
+
blindedLen: i32,
|
|
69
|
+
outPtr: usize,
|
|
70
|
+
): i32;
|
|
71
|
+
|
|
40
72
|
// HMAC key for signing session cookies. The SAME secret must be configured on
|
|
41
73
|
// every edge instance (a sealed cookie minted by one is opened by another) and
|
|
42
74
|
// must NEVER reach the client. There is no host-config secret mechanism yet, so
|
|
@@ -49,6 +81,23 @@ let __sessionSecret: Uint8Array = Uint8Array.wrap(
|
|
|
49
81
|
String.UTF8.encode('toil-dev-insecure-session-secret-CHANGE-ME'),
|
|
50
82
|
);
|
|
51
83
|
|
|
84
|
+
// OPRF master seed (RFC 9497 DeriveKeyPair input). Per-user OPRF keys are
|
|
85
|
+
// derived from this + the username, so it is a server secret of the same
|
|
86
|
+
// sensitivity as a password-hash pepper: a leak enables an offline dictionary
|
|
87
|
+
// attack (but precomputation stays impossible until it leaks). Configured at
|
|
88
|
+
// startup via `AuthService.setOprfSeed`; the DEV default below is well-known.
|
|
89
|
+
// 32 bytes (RFC 9497 Ns for ristretto255); `setOprfSeed` MUST also pass 32.
|
|
90
|
+
let __oprfSeed: Uint8Array = Uint8Array.wrap(
|
|
91
|
+
String.UTF8.encode('toil-dev-oprf-seedXXCHANGE-ME-32'),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Server static ML-KEM-768 secret (decapsulation) key. The matching public key
|
|
95
|
+
// is PINNED in the client; only the holder of this key can decapsulate, so a
|
|
96
|
+
// correct shared secret authenticates the server. Configured at startup via
|
|
97
|
+
// `AuthService.setServerKemSecretKey` (2400 bytes). Empty until set; mutual-auth
|
|
98
|
+
// calls fail closed if unset.
|
|
99
|
+
let __serverKemSk: Uint8Array = new Uint8Array(0);
|
|
100
|
+
|
|
52
101
|
// Whether the current request arrived over HTTPS. A TLS edge / proxy signals it
|
|
53
102
|
// with `x-forwarded-proto: https`; absent (plain HTTP, including `toiljs dev`)
|
|
54
103
|
// the session uses plain cookies so they actually round-trip in the browser.
|
|
@@ -278,4 +327,170 @@ export namespace AuthService {
|
|
|
278
327
|
);
|
|
279
328
|
return result == 1;
|
|
280
329
|
}
|
|
330
|
+
|
|
331
|
+
// ===================================================================
|
|
332
|
+
// Augmented-PAKE layer: OPRF keyed salt + ML-KEM mutual auth.
|
|
333
|
+
// (See server/globals/auth.ts header and the example `Auth` controller.)
|
|
334
|
+
// ===================================================================
|
|
335
|
+
|
|
336
|
+
/** ML-KEM-768 (FIPS 203) sizes. */
|
|
337
|
+
export const KEM_CIPHERTEXT_LEN: i32 = 1088;
|
|
338
|
+
export const KEM_SECRET_KEY_LEN: i32 = 2400;
|
|
339
|
+
export const KEM_PUBLIC_KEY_LEN: i32 = 1184;
|
|
340
|
+
export const SHARED_SECRET_LEN: i32 = 32;
|
|
341
|
+
/** Serialized ristretto255 OPRF element (blinded / evaluated). */
|
|
342
|
+
export const OPRF_ELEMENT_LEN: i32 = 32;
|
|
343
|
+
/** RFC 9497 DeriveKeyPair seed length (ristretto255 `Ns`). */
|
|
344
|
+
export const OPRF_SEED_LEN: i32 = 32;
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Configure the OPRF master seed (32 bytes). Per-user OPRF keys are derived
|
|
348
|
+
* from this + the username. Call once at startup from `main.ts`; identical
|
|
349
|
+
* on every instance, kept out of any client bundle.
|
|
350
|
+
*/
|
|
351
|
+
export function setOprfSeed(seed: Uint8Array): void {
|
|
352
|
+
__oprfSeed = seed;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Configure the server static ML-KEM-768 secret (decapsulation) key (2400
|
|
357
|
+
* bytes). The matching public key is pinned in the client. Call once at
|
|
358
|
+
* startup; identical on every instance, never in a client bundle.
|
|
359
|
+
*/
|
|
360
|
+
export function setServerKemSecretKey(secretKey: Uint8Array): void {
|
|
361
|
+
__serverKemSk = secretKey;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* OPRF server step: blind-evaluate the client's `blinded` element under the
|
|
366
|
+
* per-user key derived from the master seed + `username`. Returns the 32-byte
|
|
367
|
+
* evaluated element, or an empty array on any failure. The client unblinds +
|
|
368
|
+
* finalizes locally and feeds the result into Argon2id (the keyed salt).
|
|
369
|
+
*/
|
|
370
|
+
export function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array {
|
|
371
|
+
if (blinded.length != OPRF_ELEMENT_LEN) return new Uint8Array(0);
|
|
372
|
+
const info = Uint8Array.wrap(String.UTF8.encode(username));
|
|
373
|
+
const out = new Uint8Array(OPRF_ELEMENT_LEN);
|
|
374
|
+
const rc = __toilVoprfEvaluate(
|
|
375
|
+
__oprfSeed.dataStart,
|
|
376
|
+
__oprfSeed.length,
|
|
377
|
+
info.dataStart,
|
|
378
|
+
info.length,
|
|
379
|
+
blinded.dataStart,
|
|
380
|
+
blinded.length,
|
|
381
|
+
out.dataStart,
|
|
382
|
+
);
|
|
383
|
+
return rc == 0 ? out : new Uint8Array(0);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Decapsulate the client's ML-KEM ciphertext with the server static secret
|
|
388
|
+
* key, returning the 32-byte shared secret (empty on failure / unset key).
|
|
389
|
+
* Only the genuine server can produce this, so it underpins mutual auth.
|
|
390
|
+
*/
|
|
391
|
+
export function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array {
|
|
392
|
+
if (__serverKemSk.length != KEM_SECRET_KEY_LEN || ciphertext.length != KEM_CIPHERTEXT_LEN) {
|
|
393
|
+
return new Uint8Array(0);
|
|
394
|
+
}
|
|
395
|
+
const out = new Uint8Array(SHARED_SECRET_LEN);
|
|
396
|
+
const rc = __toilMlkemDecapsulate(
|
|
397
|
+
ciphertext.dataStart,
|
|
398
|
+
ciphertext.length,
|
|
399
|
+
__serverKemSk.dataStart,
|
|
400
|
+
__serverKemSk.length,
|
|
401
|
+
out.dataStart,
|
|
402
|
+
);
|
|
403
|
+
return rc == 0 ? out : new Uint8Array(0);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** SHA-256 over `data` (ambient Web Crypto), for transcript/confirm hashing. */
|
|
407
|
+
export function sha256(data: Uint8Array): Uint8Array {
|
|
408
|
+
return crypto.subtle.digest('SHA-256', data);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The canonical login message `M` (version 2), extending
|
|
413
|
+
* {@link buildLoginMessage} with the ML-KEM ciphertext so the signature
|
|
414
|
+
* binds the key-encapsulation. Byte-identical to the client's
|
|
415
|
+
* `buildLoginMessageV2`. Layout: the v1 fields, then `bytes ct`.
|
|
416
|
+
*/
|
|
417
|
+
export function buildLoginMessageV2(
|
|
418
|
+
sub: string,
|
|
419
|
+
aud: string,
|
|
420
|
+
cid: Uint8Array,
|
|
421
|
+
nonce: Uint8Array,
|
|
422
|
+
iat: u64,
|
|
423
|
+
exp: u64,
|
|
424
|
+
ciphertext: Uint8Array,
|
|
425
|
+
): Uint8Array {
|
|
426
|
+
const w = new DataWriter();
|
|
427
|
+
w.writeU8(2);
|
|
428
|
+
w.writeString(sub);
|
|
429
|
+
w.writeString(aud);
|
|
430
|
+
w.writeBytes(cid);
|
|
431
|
+
w.writeBytes(nonce);
|
|
432
|
+
w.writeU64(iat);
|
|
433
|
+
w.writeU64(exp);
|
|
434
|
+
w.writeBytes(ciphertext);
|
|
435
|
+
return w.toBytes();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Domain separator for the server's mutual-auth confirmation tag. */
|
|
439
|
+
export const SERVER_CONFIRM_LABEL: string = 'toil-server-confirm-v1';
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The server's mutual-auth confirmation tag: `SHA-256(label || sharedSecret
|
|
443
|
+
* || transcriptHash)`. Only a server that correctly decapsulated (i.e. holds
|
|
444
|
+
* the KEM secret key) can produce it, so the client verifying it proves the
|
|
445
|
+
* server's identity. `transcriptHash` is `sha256(M)`.
|
|
446
|
+
*
|
|
447
|
+
* NOTE: a SHA-256 MAC keyed by the high-entropy shared secret is adequate
|
|
448
|
+
* here and keeps the client (insecure-context) simple; a hardened build
|
|
449
|
+
* should derive a session key with HKDF and bind it to the channel.
|
|
450
|
+
*/
|
|
451
|
+
export function serverConfirmTag(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array {
|
|
452
|
+
const label = Uint8Array.wrap(String.UTF8.encode(SERVER_CONFIRM_LABEL));
|
|
453
|
+
const buf = new Uint8Array(label.length + sharedSecret.length + transcriptHash.length);
|
|
454
|
+
buf.set(label, 0);
|
|
455
|
+
buf.set(sharedSecret, label.length);
|
|
456
|
+
buf.set(transcriptHash, label.length + sharedSecret.length);
|
|
457
|
+
return crypto.subtle.digest('SHA-256', buf);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Registration proof-of-possession context (binds a signature to "register"
|
|
461
|
+
* so it can never validate as a login). Byte-identical to the client. */
|
|
462
|
+
export const REGISTER_CONTEXT: string = 'qauth:register:v1';
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The registration PoP message: `u8(1) str(username) bytes(publicKey)`,
|
|
466
|
+
* signed by the client under {@link REGISTER_CONTEXT}. Verifying it proves
|
|
467
|
+
* the registrant holds the secret key for the public key it is registering.
|
|
468
|
+
*/
|
|
469
|
+
export function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array {
|
|
470
|
+
const w = new DataWriter();
|
|
471
|
+
w.writeU8(1);
|
|
472
|
+
w.writeString(username);
|
|
473
|
+
w.writeBytes(publicKey);
|
|
474
|
+
return w.toBytes();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Verify a registration proof-of-possession over `message` against the
|
|
478
|
+
* submitted `publicKey`, under {@link REGISTER_CONTEXT}. */
|
|
479
|
+
export function verifyRegister(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool {
|
|
480
|
+
if (publicKey.length != PUBLIC_KEY_LEN || signature.length != SIGNATURE_LEN) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
const ctx = Uint8Array.wrap(String.UTF8.encode(REGISTER_CONTEXT));
|
|
484
|
+
const result = __toilMldsaVerify(
|
|
485
|
+
publicKey.dataStart,
|
|
486
|
+
publicKey.length,
|
|
487
|
+
message.dataStart,
|
|
488
|
+
message.length,
|
|
489
|
+
signature.dataStart,
|
|
490
|
+
signature.length,
|
|
491
|
+
ctx.dataStart,
|
|
492
|
+
ctx.length,
|
|
493
|
+
);
|
|
494
|
+
return result == 1;
|
|
495
|
+
}
|
|
281
496
|
}
|
package/src/cli/diagnostics.ts
CHANGED
|
@@ -96,6 +96,28 @@ export function checkPackageManager(lockfiles: readonly string[]): Check {
|
|
|
96
96
|
return { id: 'pm', label: 'Package manager', status: 'pass', detail: lockfiles.join(', ') };
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Flags `npx toiljs ...` inside package.json scripts. Under `npm run`, `node_modules/.bin` is
|
|
101
|
+
* already on PATH, so the `npx` is redundant; worse, the extra `npx` process puts the console in
|
|
102
|
+
* raw / VT-input mode and does not restore it when the run is Ctrl+C'd, leaving the terminal
|
|
103
|
+
* echoing arrow keys as `^[[A` and mis-reading typed input (Windows cmd especially). The scaffold
|
|
104
|
+
* uses a bare `toiljs <cmd>`; this catches projects whose scripts wrap it in `npx`.
|
|
105
|
+
*/
|
|
106
|
+
export function checkDevScripts(scripts: Record<string, string>): Check {
|
|
107
|
+
const NPX_TOILJS = /(?:^|[\s&|;(])npx\s+toiljs\b/;
|
|
108
|
+
const offenders = Object.keys(scripts).filter((name) => NPX_TOILJS.test(scripts[name] ?? ''));
|
|
109
|
+
if (offenders.length === 0) {
|
|
110
|
+
return { id: 'scripts-npx', label: 'Scripts', status: 'pass' };
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
id: 'scripts-npx',
|
|
114
|
+
label: 'Scripts',
|
|
115
|
+
status: 'warn',
|
|
116
|
+
detail: `${offenders.join(', ')} run via "npx toiljs"`,
|
|
117
|
+
fix: 'Drop npx: use "toiljs <cmd>" (npm run already puts node_modules/.bin on PATH). The npx layer can leave the terminal in raw mode after Ctrl+C, which garbles the shell.',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
99
121
|
export function checkToiljsInstalled(version: string | null): Check {
|
|
100
122
|
return version
|
|
101
123
|
? { id: 'toiljs', label: 'toiljs', status: 'pass', detail: version }
|
package/src/cli/doctor.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type Check,
|
|
17
17
|
checkBasePath,
|
|
18
18
|
checkConfigLoads,
|
|
19
|
+
checkDevScripts,
|
|
19
20
|
checkDir,
|
|
20
21
|
checkDuplicatePatterns,
|
|
21
22
|
type CheckGroup,
|
|
@@ -670,6 +671,7 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
|
|
|
670
671
|
checkRoutesPresent(routes.length),
|
|
671
672
|
checkDuplicatePatterns(mainPatterns),
|
|
672
673
|
checkRelativeAssets(assetIssues),
|
|
674
|
+
checkDevScripts(projectPkg ? stringRecord(projectPkg.scripts) : {}),
|
|
673
675
|
],
|
|
674
676
|
},
|
|
675
677
|
{
|
package/src/client/auth.ts
CHANGED
|
@@ -11,13 +11,39 @@
|
|
|
11
11
|
* JSON, byte-identical to the server's `AuthService.buildLoginMessage`.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { argon2id, sha256 } from 'hash-wasm';
|
|
14
|
+
import { argon2id, sha256, createSHA256 } 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 separator for the server's mutual-auth confirmation tag. */
|
|
26
|
+
export const SERVER_CONFIRM_LABEL = 'toil-server-confirm-v1';
|
|
27
|
+
|
|
28
|
+
/** ML-KEM-768 sizes (FIPS 203). */
|
|
29
|
+
export const KEM_PUBLIC_KEY_LEN = 1184;
|
|
30
|
+
export const KEM_CIPHERTEXT_LEN = 1088;
|
|
31
|
+
export const SHARED_SECRET_LEN = 32;
|
|
32
|
+
|
|
33
|
+
/** Lowercase-hex -> bytes. */
|
|
34
|
+
function fromHex(hex: string): Uint8Array {
|
|
35
|
+
const out = new Uint8Array(hex.length / 2);
|
|
36
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The server's PINNED static ML-KEM-768 public key. The client encapsulates to
|
|
42
|
+
* it; only the genuine server (holder of the matching secret key) can
|
|
43
|
+
* decapsulate, so a valid confirmation tag authenticates the server. This is
|
|
44
|
+
* the demo dev key; a real deployment pins its own (and rotates it).
|
|
45
|
+
*/
|
|
46
|
+
export const SERVER_KEM_PUBLIC_KEY = fromHex('29d765e8083182891302569b3712a856e564fdd484b0706b0c68568d5ab7edc742cf74459d64595455a60f267973aa55e43c5be61925a3822eafcca445e36dc4655636e31e6fc9bec338b253f94290008ef7f40dbddb49c15c690f6755a23a1b3c85cfd5207e71a607086a6fc6d74a05080f43276901a19cafdb8de7771d58ea07f0f1056b905127b22223d08e75173199f13ab13c5dcd3b51ac784f84e520484a262b845a897c41cf27324ab6ba545c78c9ccab361051e0bba53498af26240fa0d566d1572684f4b42e253e6d052c848650915063c35641e1121ef8d9cfd17b667b351103c56d195007c9376d0c08aa268396814490eab4c364175a94533267a1933862cc4c33bcf0a13d1fa2b9d6c5082eeca1480672f2526cbe013beff14dc908a386e0b633c8761023cbed760deac6709bc328d865ac82e12307b673d96711dbb27a4d939230d25b53d594169a318be0200fa33550e9418e2a3b30e9719edc09d5fc4306f1abfd021eab14637a8a72c5931d25dc9b56db0e6ab677522b10f25307dbb804a6774ce05b87b0976a4b227bfe6caf20a79e64004fbd27b1eea018b3ab8ffa629f2dc87f19278f95168e94e44660a3370c537795678eb2f056260609769740583b51b291862927a1938737c6a37f40b78f00671cccbcb88ac3427b37915ed58782998f84051647707d48995472baad3f64a7cca54e1c0734db08751c614a34f28b84f2c1b5a6817355ab61957c486b7acffbc092bc8a7b46387f33b53ed372f7168d31a71cd008539928b0cdf91e835aa97f6a2be6d327b87a6ae478701d75a59a25179cb14997bb2552853014724170a1c49b82c2bcebc3279024e1fa44c53c7afdc43f0bd22116490f3b74c90e7296be58b9a91168f2fa0c3d378a3bcac959f357825c9976a8c9ee944f29b45e96d7345d9b478431a20cf1c5d3a3227c717fd204619777636c0cb140db5c50d2a3302334461030bee34e4eb1a6f02b733f9ccda4290fa168bc039568373241542728d00030d1f251e83737cb215adbdc1de75978675a0cd0d75b12748abdda7a9852629c63697d145af2c69854b06e03f37c4b064e4c9a4c03f2ad4d081e70180e9547247921918118086b62b4f7727f46b24e3e79ba3f28209f32b5102035bf935856232f83642268c0292ec6bf8e9462382163d30a20b4bcb7b4439310ec9d0a148193907fc07697342967cf1a16c6b3c71558951fa915400736cf699262b54b723abb2ecc27b74b68ee494287595ef818388adb49e883c67bfa5c226c0eef037a0851a29d34675912c1ea1068310b6dfcd017c809c8fbfc2c3ae78dfef07299960eeefba182662a90fa422c1790f356a2ea909012b15623a9b9e450a282cb530589a68368b3583159d9010ac3e52cc974753c342e58279516339dfb691df94b13a223ad97eb6a09c21dafe6304a3642d6d2067b5238497661fe88ad1227ca3557be2a576b6e17c5a7f997ea07929e76407e376aba74c44cd8504804776f39bbb8327624188a63501e83b404d9438cade0b11dc3ac61856447fb072b91761c228878f01b2eb6b4b21ba664c2c75882431603b25a449ffeb8410b910558581777562aa9b2181fd9c04713ad9326462d3e842121c4997f9aa932417c67851625816de66e0d65637434629f39');
|
|
21
47
|
|
|
22
48
|
export const PUBLIC_KEY_LEN = 1312;
|
|
23
49
|
export const SECRET_KEY_LEN = 2560;
|
|
@@ -38,16 +64,6 @@ export interface KdfParams {
|
|
|
38
64
|
readonly salt: Uint8Array;
|
|
39
65
|
}
|
|
40
66
|
|
|
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
67
|
/** Overwrite a secret buffer with random bytes, then zero. Best-effort: JS GC
|
|
52
68
|
* cannot scrub copies, so we never store or close over secrets beyond one call. */
|
|
53
69
|
function wipe(buf: Uint8Array): void {
|
|
@@ -55,10 +71,13 @@ function wipe(buf: Uint8Array): void {
|
|
|
55
71
|
buf.fill(0);
|
|
56
72
|
}
|
|
57
73
|
|
|
58
|
-
/** Argon2id(
|
|
59
|
-
|
|
74
|
+
/** Argon2id(oprfOutput, salt; m,t,p, len=32) -> 32-byte ML-DSA seed. The KDF
|
|
75
|
+
* input is the OPRF output (the keyed salt), NOT the raw password: the password
|
|
76
|
+
* is first run through the server-keyed OPRF, so a server breach yields no
|
|
77
|
+
* precomputable salt. */
|
|
78
|
+
async function deriveSeed(oprfOutput: Uint8Array, kdf: KdfParams): Promise<Uint8Array> {
|
|
60
79
|
return argon2id({
|
|
61
|
-
password:
|
|
80
|
+
password: oprfOutput,
|
|
62
81
|
salt: kdf.salt,
|
|
63
82
|
iterations: kdf.iterations,
|
|
64
83
|
parallelism: kdf.parallelism,
|
|
@@ -68,6 +87,37 @@ async function deriveSeed(password: string, kdf: KdfParams): Promise<Uint8Array>
|
|
|
68
87
|
});
|
|
69
88
|
}
|
|
70
89
|
|
|
90
|
+
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s);
|
|
91
|
+
|
|
92
|
+
/** SHA-256 over `data` -> 32 raw bytes. Uses hash-wasm (pure WASM), so it works
|
|
93
|
+
* in an insecure context (plain HTTP) where `crypto.subtle` is undefined. */
|
|
94
|
+
async function sha256Bytes(data: Uint8Array): Promise<Uint8Array> {
|
|
95
|
+
const h = await createSHA256();
|
|
96
|
+
h.init();
|
|
97
|
+
h.update(data);
|
|
98
|
+
return h.digest('binary') as unknown as Uint8Array;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
|
102
|
+
let n = 0;
|
|
103
|
+
for (const p of parts) n += p.length;
|
|
104
|
+
const out = new Uint8Array(n);
|
|
105
|
+
let off = 0;
|
|
106
|
+
for (const p of parts) {
|
|
107
|
+
out.set(p, off);
|
|
108
|
+
off += p.length;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Length-checked constant-time-ish equality (no early-exit on content). */
|
|
114
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
115
|
+
if (a.length !== b.length) return false;
|
|
116
|
+
let diff = 0;
|
|
117
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
118
|
+
return diff === 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
71
121
|
/** The canonical login message `M`, fixed binary layout (see the server's
|
|
72
122
|
* `AuthService.buildLoginMessage`). Both ends MUST produce identical bytes. */
|
|
73
123
|
export function buildLoginMessage(
|
|
@@ -89,6 +139,37 @@ export function buildLoginMessage(
|
|
|
89
139
|
.toBytes();
|
|
90
140
|
}
|
|
91
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
|
+
ciphertext: Uint8Array,
|
|
153
|
+
): Uint8Array {
|
|
154
|
+
return new DataWriter()
|
|
155
|
+
.writeU8(2)
|
|
156
|
+
.writeString(sub)
|
|
157
|
+
.writeString(aud)
|
|
158
|
+
.writeBytes(cid)
|
|
159
|
+
.writeBytes(nonce)
|
|
160
|
+
.writeU64(iat)
|
|
161
|
+
.writeU64(exp)
|
|
162
|
+
.writeBytes(ciphertext)
|
|
163
|
+
.toBytes();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Registration proof-of-possession message: `u8(1) str(username) bytes(pk)`,
|
|
167
|
+
* signed under {@link REGISTER_CONTEXT}. Byte-identical to the server's
|
|
168
|
+
* `buildRegisterMessage`. */
|
|
169
|
+
export function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array {
|
|
170
|
+
return new DataWriter().writeU8(1).writeString(username).writeBytes(publicKey).toBytes();
|
|
171
|
+
}
|
|
172
|
+
|
|
92
173
|
// ---- wire codecs (the example `Auth` @rest controller mirrors these) -------
|
|
93
174
|
|
|
94
175
|
function decodeKdf(r: DataReader): KdfParams {
|
|
@@ -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,107 @@ export interface AuthOptions {
|
|
|
127
199
|
}
|
|
128
200
|
|
|
129
201
|
/**
|
|
130
|
-
* Register a new account
|
|
131
|
-
*
|
|
202
|
+
* Register a new account (OPAQUE-style). 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
|
if (finish.readU8() !== 0) throw new Error('auth: registration rejected');
|
|
161
253
|
}
|
|
162
254
|
|
|
163
255
|
/**
|
|
164
|
-
* Log in
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
256
|
+
* Log in (OPAQUE-style, with ML-KEM mutual auth). Steps:
|
|
257
|
+
* 1. Blind the password; `login/start` returns the challenge + the OPRF
|
|
258
|
+
* evaluation (a fully-formed response even for unknown users -> no oracle).
|
|
259
|
+
* 2. Finalize the OPRF -> keyed salt -> seed -> ML-DSA keypair.
|
|
260
|
+
* 3. Encapsulate a shared secret to the PINNED server ML-KEM public key, build
|
|
261
|
+
* the v2 message (which binds the ciphertext), and sign it once.
|
|
262
|
+
* 4. Submit `{cid, ct, signature}`; the server consumes the challenge, verifies
|
|
263
|
+
* the signature, decapsulates, and returns a confirmation tag.
|
|
264
|
+
* 5. Verify that tag against our own shared secret -> the server proved it
|
|
265
|
+
* holds the KEM secret key (mutual authentication).
|
|
266
|
+
* The secret key, seed, and shared secret are wiped as soon as they are used.
|
|
267
|
+
* Returns the opaque session token. Throws (one generic message) on any failure.
|
|
169
268
|
*/
|
|
170
269
|
export async function login(username: string, password: string, opts: AuthOptions = {}): Promise<Uint8Array> {
|
|
171
270
|
const baseUrl = opts.baseUrl ?? '/auth';
|
|
271
|
+
const oprf = ristretto255_oprf.oprf;
|
|
272
|
+
const pw = utf8(password.normalize('NFKC'));
|
|
172
273
|
|
|
173
|
-
// 1.
|
|
174
|
-
const
|
|
175
|
-
|
|
274
|
+
// 1. Blinded login/start.
|
|
275
|
+
const { blind, blinded } = oprf.blind(pw);
|
|
276
|
+
const r = await postBinary(
|
|
277
|
+
baseUrl,
|
|
278
|
+
'/login/start',
|
|
279
|
+
new DataWriter().writeString(username).writeBytes(blinded).toBytes(),
|
|
176
280
|
);
|
|
177
|
-
|
|
281
|
+
const cid = r.readBytes();
|
|
282
|
+
const aud = r.readString();
|
|
283
|
+
const kdf = decodeKdf(r);
|
|
284
|
+
const nonce = r.readBytes();
|
|
285
|
+
const iat = r.readU64();
|
|
286
|
+
const exp = r.readU64();
|
|
287
|
+
const evaluated = r.readBytes();
|
|
178
288
|
// Client-side fast-fail only; the server re-checks expiry authoritatively.
|
|
179
|
-
if (BigInt(Math.floor(Date.now() / 1000)) >=
|
|
289
|
+
if (BigInt(Math.floor(Date.now() / 1000)) >= exp) throw new Error('auth: challenge expired');
|
|
290
|
+
|
|
291
|
+
// 2. OPRF -> keyed salt -> seed.
|
|
292
|
+
const oprfOutput = oprf.finalize(pw, blind, evaluated);
|
|
293
|
+
const seed = await deriveSeed(oprfOutput, kdf);
|
|
180
294
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
const
|
|
295
|
+
// 3. Encapsulate to the pinned server KEM key, build + sign the v2 message.
|
|
296
|
+
const { cipherText, sharedSecret } = ml_kem768.encapsulate(SERVER_KEM_PUBLIC_KEY);
|
|
297
|
+
const message = buildLoginMessageV2(username, aud, cid, nonce, iat, exp, cipherText);
|
|
184
298
|
let signature: Uint8Array;
|
|
185
299
|
try {
|
|
186
300
|
const kp = ml_dsa44.keygen(seed);
|
|
187
301
|
try {
|
|
188
|
-
signature = ml_dsa44.sign(message, kp.secretKey, { context:
|
|
302
|
+
signature = ml_dsa44.sign(message, kp.secretKey, { context: utf8(LOGIN_CONTEXT) });
|
|
189
303
|
} finally {
|
|
190
304
|
wipe(kp.secretKey);
|
|
191
305
|
}
|
|
@@ -194,15 +308,29 @@ export async function login(username: string, password: string, opts: AuthOption
|
|
|
194
308
|
}
|
|
195
309
|
if (signature.length !== SIGNATURE_LEN) throw new Error('auth: bad signature length');
|
|
196
310
|
|
|
197
|
-
//
|
|
198
|
-
// rebuilds the message from its own stored values, and verifies.
|
|
311
|
+
// 4. Submit {cid, ct, signature}.
|
|
199
312
|
const res = await postBinary(
|
|
200
313
|
baseUrl,
|
|
201
314
|
'/login/finish',
|
|
202
|
-
new DataWriter().writeBytes(
|
|
315
|
+
new DataWriter().writeBytes(cid).writeBytes(cipherText).writeBytes(signature).toBytes(),
|
|
203
316
|
);
|
|
204
|
-
if (res.readU8() !== 0)
|
|
205
|
-
|
|
317
|
+
if (res.readU8() !== 0) {
|
|
318
|
+
wipe(sharedSecret);
|
|
319
|
+
throw new Error('auth: login failed');
|
|
320
|
+
}
|
|
321
|
+
const session = res.readBytes();
|
|
322
|
+
const serverConfirm = res.readBytes();
|
|
323
|
+
|
|
324
|
+
// 5. Mutual auth: only a server that decapsulated correctly can produce
|
|
325
|
+
// SHA-256(label || sharedSecret || sha256(M)). Verify before trusting.
|
|
326
|
+
const transcriptHash = await sha256Bytes(message);
|
|
327
|
+
const expected = await sha256Bytes(
|
|
328
|
+
concatBytes(utf8(SERVER_CONFIRM_LABEL), sharedSecret, transcriptHash),
|
|
329
|
+
);
|
|
330
|
+
wipe(sharedSecret);
|
|
331
|
+
if (!bytesEqual(expected, serverConfirm)) throw new Error('auth: server authentication failed');
|
|
332
|
+
|
|
333
|
+
return session; // session token
|
|
206
334
|
}
|
|
207
335
|
|
|
208
336
|
/** Lowercase hex of `bytes`. */
|
package/src/client/index.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
export { mount } from './routing/mount.js';
|
|
12
12
|
export { Router } from './routing/Router.js';
|
|
13
13
|
export { Auth, register as authRegister, login as authLogin, proveIdentity, buildLoginMessage, LOGIN_CONTEXT } from './auth.js';
|
|
14
|
-
export type { KdfParams,
|
|
14
|
+
export type { KdfParams, AuthOptions, IdentityProof } 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): 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 buildLoginMessageV2(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64, ciphertext: Uint8Array): Uint8Array; function serverConfirmTag(sharedSecret: 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
|
/**
|