cursedbelt-server 4.23.0 → 4.24.1
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/README.md +8 -0
- package/dist/server/worker-argon2/_nodeRefuses.d.ts +1 -0
- package/dist/server/worker-argon2/_nodeRefuses.js +13 -0
- package/dist/server/worker-argon2/argon2.d.ts +11 -0
- package/dist/server/worker-argon2/argon2.js +71 -0
- package/dist/server/worker-argon2/index.d.ts +9 -0
- package/dist/server/worker-argon2/index.js +9 -0
- package/dist/server/worker-argon2/password.d.ts +26 -0
- package/dist/server/worker-argon2/password.js +164 -0
- package/package.json +9 -1
- package/src/server/telemetryIsWorkerSafe.spec.ts +31 -0
- package/src/server/worker-argon2/_nodeRefuses.ts +16 -0
- package/src/server/worker-argon2/argon2.ts +85 -0
- package/src/server/worker-argon2/index.spec.ts +162 -0
- package/src/server/worker-argon2/index.ts +9 -0
- package/src/server/worker-argon2/password.ts +185 -0
- package/src/server/worker-argon2/wasm.d.ts +9 -0
package/README.md
CHANGED
|
@@ -49,6 +49,14 @@ about the whole options object rather than the one field. Use `serveBun(app, { w
|
|
|
49
49
|
which takes it as a genuinely optional field and makes the two concrete calls itself.
|
|
50
50
|
`src/server/serveBunOverload.spec.ts` runs `tsc` on the trap and goes red when bun's types change.
|
|
51
51
|
|
|
52
|
+
## 🔴 argon2id on a Worker: `cursedbelt-server/worker-argon2`
|
|
53
|
+
|
|
54
|
+
A Worker may not COMPILE WebAssembly, and pure-JS argon2id costs ~2.8 s of billed CPU per
|
|
55
|
+
derivation on workerd (collections' unlock was 8.5 s). `installBunPasswordShim()` first thing in
|
|
56
|
+
the Worker's `fetch` gives `Bun.password` to the auth code, backed by `argon2id`'s two `.wasm`
|
|
57
|
+
files as static imports that wrangler bundles from `node_modules`. No wrangler rule and no local
|
|
58
|
+
`.wasm` declaration are needed. `src/server/worker-argon2/argon2.ts` has the measurements.
|
|
59
|
+
|
|
52
60
|
## Standards
|
|
53
61
|
|
|
54
62
|
`docs/retention.md`, `docs/activity.md`, `docs/notifications.md`, `docs/engagement.md` — the
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Loaded only under Node — the `node` condition of `cursedbelt-server/worker-argon2`.
|
|
2
|
+
//
|
|
3
|
+
// This subpath's two `.wasm` files are STATIC imports, which is the one shape workerd will run
|
|
4
|
+
// (`./argon2.ts`). Node 24 reads the same import through its WebAssembly ESM integration and
|
|
5
|
+
// instantiates the module itself, resolving its imports from a package called `env` — so the
|
|
6
|
+
// real module fails under Node with `ERR_MODULE_NOT_FOUND: Cannot find package 'env'`, deep in
|
|
7
|
+
// `argon2id/dist/no-simd.wasm` (measured by tools/proveInstall.ts publishing 4.24.0, 2026-09-23).
|
|
8
|
+
// Nothing in this generation runs it on Node: a Worker gets it through wrangler (`workerd`
|
|
9
|
+
// resolves `import`), a Mac server through Bun (`bun`), and a Mac server already HAS
|
|
10
|
+
// `Bun.password`. So Node gets this sentence instead. Same shape as `../_bunOnly.ts`.
|
|
11
|
+
throw new Error("cursedbelt-server/worker-argon2 runs on workerd (through wrangler) and Bun — not Node, which cannot load its static `.wasm` imports. " +
|
|
12
|
+
"A Node server should hash with a native argon2 (`@node-rs/argon2`).");
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type computeHash } from "argon2id/lib/setup.js";
|
|
2
|
+
/** What a `.wasm` import is: a compiled module under wrangler, a file path under Bun. */
|
|
3
|
+
export type WasmImport = WebAssembly.Module | string;
|
|
4
|
+
/**
|
|
5
|
+
* Turn one `.wasm` import into an instance. 🔴 A `Module` is instantiated and NEVER compiled —
|
|
6
|
+
* that is the whole workerd rule — and only a path (Bun) is read and compiled.
|
|
7
|
+
*/
|
|
8
|
+
export declare function instantiateWasm(source: WasmImport, imports: WebAssembly.Imports): Promise<WebAssembly.WebAssemblyInstantiatedSource>;
|
|
9
|
+
/** Build a hasher from the two binaries — SIMD first, the plain one if SIMD will not load. */
|
|
10
|
+
export declare function loadArgon2(simd?: WasmImport, noSimd?: WasmImport): Promise<computeHash>;
|
|
11
|
+
export declare function argon2(): Promise<computeHash>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* argon2id as WebAssembly the Worker was HANDED, never WebAssembly it compiles.
|
|
3
|
+
*
|
|
4
|
+
* ## 🔴 Why this file exists: one unlock was 8.5–15 seconds of billed CPU
|
|
5
|
+
*
|
|
6
|
+
* `collections` averaged **489 CPU-ms per request on 2026-09-22 with a 15.2 s p99** (Cloudflare
|
|
7
|
+
* GraphQL, `workersInvocationsAdaptive`). Every second of it was `POST /__lock/unlock`: a real
|
|
8
|
+
* `wrangler tail` of the stage walk on 2026-09-23 read **8,519 CPU-ms and 10.4 s wall** for that
|
|
9
|
+
* one request and 10–38 ms for each of the other eleven. The lock verifies every account's
|
|
10
|
+
* argon2id hash (no early exit — `cursedbelt-server/master-lock` explains the timing oracle) plus
|
|
11
|
+
* a timing equalizer, so production's two accounts are three to four derivations per unlock, and
|
|
12
|
+
* `@noble/hashes`' pure-JS argon2id costs ~2.8 s each on workerd — five times the 562 ms it
|
|
13
|
+
* costs on this Mac. The owner waited ten seconds at the lock page, and a third account would
|
|
14
|
+
* have put the unlock within reach of the Worker's 30 s CPU ceiling, where it is killed and
|
|
15
|
+
* reads as a wrong password.
|
|
16
|
+
*
|
|
17
|
+
* ## 🔴 Why this shape: workerd refuses to COMPILE WebAssembly, not to RUN it
|
|
18
|
+
*
|
|
19
|
+
* `hash-wasm` failed here on 2026-09-18 with `Wasm code generation disallowed by embedder`
|
|
20
|
+
* because it compiles bytes it carries inline (`./password.ts` has the whole story). A
|
|
21
|
+
* `.wasm` file that is a STATIC import is different: wrangler bundles it as a
|
|
22
|
+
* `CompiledWasm` module and the runtime hands this code a ready `WebAssembly.Module`, which
|
|
23
|
+
* `WebAssembly.instantiate(module, imports)` may instantiate. `argon2id` (openpgpjs, MIT) ships
|
|
24
|
+
* its two binaries as plain files and lets the caller do the instantiating, which is exactly the
|
|
25
|
+
* seam a Worker needs. Its default entry inlines base64 and compiles it — so it is never imported.
|
|
26
|
+
*
|
|
27
|
+
* Under Bun (every test, and the Mac) the same import is a PATH STRING, so the bytes are read
|
|
28
|
+
* and compiled here — which Bun allows. `index.spec.ts` drives the Worker's branch too, with
|
|
29
|
+
* `WebAssembly.compile` made to throw exactly as workerd throws.
|
|
30
|
+
*
|
|
31
|
+
* ## Why it lives in `cursedbelt-server` (4.24.0)
|
|
32
|
+
*
|
|
33
|
+
* It was born in `apps/collections/worker/argon2.ts` (commit 7996ded) while `apps/vault` and
|
|
34
|
+
* `apps/patterns` still carried the pure-JS copy, so every sign-in there billed seconds. One copy
|
|
35
|
+
* here, `argon2id` a pinned dependency of this package: its two `.wasm` files ship in its own
|
|
36
|
+
* `files`, and wrangler — which resolves this subpath's `import` condition, `dist/…/argon2.js`,
|
|
37
|
+
* where tsc leaves the bare `argon2id/dist/*.wasm` specifiers untouched — bundles them from
|
|
38
|
+
* `node_modules` as `CompiledWasm`. `index.spec.ts` asserts `dist` still carries them that way.
|
|
39
|
+
*/
|
|
40
|
+
import noSimdWasm from "argon2id/dist/no-simd.wasm";
|
|
41
|
+
import simdWasm from "argon2id/dist/simd.wasm";
|
|
42
|
+
import setupWasm, {} from "argon2id/lib/setup.js";
|
|
43
|
+
/**
|
|
44
|
+
* Turn one `.wasm` import into an instance. 🔴 A `Module` is instantiated and NEVER compiled —
|
|
45
|
+
* that is the whole workerd rule — and only a path (Bun) is read and compiled.
|
|
46
|
+
*/
|
|
47
|
+
export async function instantiateWasm(source, imports) {
|
|
48
|
+
const module = typeof source === "string" ? await WebAssembly.compile(await Bun.file(source).arrayBuffer()) : source;
|
|
49
|
+
const instance = await WebAssembly.instantiate(module, imports);
|
|
50
|
+
return { module, instance };
|
|
51
|
+
}
|
|
52
|
+
/** Build a hasher from the two binaries — SIMD first, the plain one if SIMD will not load. */
|
|
53
|
+
export function loadArgon2(simd = simdWasm, noSimd = noSimdWasm) {
|
|
54
|
+
return setupWasm((imports) => instantiateWasm(simd, imports), (imports) => instantiateWasm(noSimd, imports));
|
|
55
|
+
}
|
|
56
|
+
/*
|
|
57
|
+
* 🔴 ONE hasher per isolate, built on first use. Not request state — it holds no password and no
|
|
58
|
+
* result between calls (the library clears its memory after each) — so this is the one kind of
|
|
59
|
+
* module-scope value a Worker may keep. The reason to keep it is MEMORY: each
|
|
60
|
+
* instance owns a 65 MB `WebAssembly.Memory` that can never shrink, and an unlock runs three or
|
|
61
|
+
* four derivations back to back; one per call would stack up to four of them against a 128 MB
|
|
62
|
+
* isolate before a collector ran. A load that FAILS is not cached, so the next unlock retries.
|
|
63
|
+
*/
|
|
64
|
+
let shared = null;
|
|
65
|
+
export function argon2() {
|
|
66
|
+
shared ??= loadArgon2().catch((error) => {
|
|
67
|
+
shared = null;
|
|
68
|
+
throw error;
|
|
69
|
+
});
|
|
70
|
+
return shared;
|
|
71
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/worker-argon2` — argon2id a Cloudflare Worker can afford, and the
|
|
3
|
+
* `Bun.password` shim that lets the same auth code verify on the Mac and on the edge.
|
|
4
|
+
*
|
|
5
|
+
* `./argon2.ts` is the WHY (8.5 s of billed CPU per unlock in pure JS; workerd forbids compiling
|
|
6
|
+
* WebAssembly, so the `.wasm` is a static import wrangler bundles); `./password.ts` is the shim.
|
|
7
|
+
*/
|
|
8
|
+
export { argon2, instantiateWasm, loadArgon2, type WasmImport } from "./argon2.js";
|
|
9
|
+
export { type BunPasswordShim, installBunPasswordShim, parsePhc, workerPassword } from "./password.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/worker-argon2` — argon2id a Cloudflare Worker can afford, and the
|
|
3
|
+
* `Bun.password` shim that lets the same auth code verify on the Mac and on the edge.
|
|
4
|
+
*
|
|
5
|
+
* `./argon2.ts` is the WHY (8.5 s of billed CPU per unlock in pure JS; workerd forbids compiling
|
|
6
|
+
* WebAssembly, so the `.wasm` is a static import wrangler bundles); `./password.ts` is the shim.
|
|
7
|
+
*/
|
|
8
|
+
export { argon2, instantiateWasm, loadArgon2 } from "./argon2.js";
|
|
9
|
+
export { installBunPasswordShim, parsePhc, workerPassword } from "./password.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
interface Phc {
|
|
2
|
+
m: number;
|
|
3
|
+
t: number;
|
|
4
|
+
p: number;
|
|
5
|
+
salt: Uint8Array;
|
|
6
|
+
hash: Uint8Array;
|
|
7
|
+
}
|
|
8
|
+
/** `$argon2id$v=19$m=65536,t=2,p=1$<salt>$<hash>` → its parts, or null. */
|
|
9
|
+
export declare function parsePhc(encoded: string): Phc | null;
|
|
10
|
+
export interface BunPasswordShim {
|
|
11
|
+
verify(password: string, hash: string): Promise<boolean>;
|
|
12
|
+
hash(password: string, options?: {
|
|
13
|
+
algorithm?: string;
|
|
14
|
+
memoryCost?: number;
|
|
15
|
+
timeCost?: number;
|
|
16
|
+
}): Promise<string>;
|
|
17
|
+
}
|
|
18
|
+
export declare const workerPassword: BunPasswordShim;
|
|
19
|
+
/**
|
|
20
|
+
* Install the shim, once, before any route can run.
|
|
21
|
+
*
|
|
22
|
+
* Idempotent, and it never overwrites a real `Bun` — so importing this module in a
|
|
23
|
+
* Bun test does nothing, which is what keeps the gate honest about the Mac.
|
|
24
|
+
*/
|
|
25
|
+
export declare function installBunPasswordShim(): void;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* argon2id on `workerd`, where the obvious answer does not work.
|
|
3
|
+
*
|
|
4
|
+
* ## 🔴 The finding, and it applies to every app in this fleet
|
|
5
|
+
*
|
|
6
|
+
* **A Worker may not compile WebAssembly at runtime.** `hash-wasm` — the first and
|
|
7
|
+
* most natural choice, and the one every search result recommends — inlines its
|
|
8
|
+
* argon2 module as base64 and calls `WebAssembly.compile(bytes)` on first use.
|
|
9
|
+
* Deployed here on 2026-09-18, that produced:
|
|
10
|
+
*
|
|
11
|
+
* ```
|
|
12
|
+
* [patterns] argon2 verification threw: ← the pilot's log line, quoted verbatim
|
|
13
|
+
* WebAssembly.compile(): Wasm code generation disallowed by embedder
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* It **builds, deploys and starts perfectly**, and fails only when somebody tries
|
|
17
|
+
* to sign in — where it is indistinguishable from a wrong password, because a KDF
|
|
18
|
+
* that throws must fail closed. That combination is the worst shape a defect can
|
|
19
|
+
* have, and it is why `verify` below logs the error: a silent `false` cost a
|
|
20
|
+
* diagnosis on the day this was written. On Workers, WASM must be a *static
|
|
21
|
+
* import* of a `.wasm` file that wrangler bundles as a module; a library that
|
|
22
|
+
* carries its bytes inline can never satisfy that.
|
|
23
|
+
*
|
|
24
|
+
* So this runs **argon2id as WebAssembly that wrangler bundles as a static module** —
|
|
25
|
+
* `./argon2.ts`, which instantiates a `WebAssembly.Module` it was handed and never compiles
|
|
26
|
+
* one. Until 2026-09-23 it was `@noble/hashes/argon2`, pure JavaScript: byte-identical output
|
|
27
|
+
* and 562 ms per verification on this Mac, which read as acceptable — and on workerd it was
|
|
28
|
+
* **~2.8 s per derivation**, so one unlock (every account's hash plus the timing equalizer)
|
|
29
|
+
* billed 8.5 s on the stage and 15 s in production, and was 489 CPU-ms averaged over every
|
|
30
|
+
* request the app served on 2026-09-22. `./argon2.ts` carries the measurement.
|
|
31
|
+
* `cursedbelt-server/login-throttle` is what holds the line against a flood, not the KDF's cost.
|
|
32
|
+
*
|
|
33
|
+
* ## What this buys, and it is the reason to pay for argon2id at all
|
|
34
|
+
*
|
|
35
|
+
* **The owner's existing password hash works on a Worker unchanged.** No re-hash,
|
|
36
|
+
* no "sign in again on the new host", and above all no downgrade to PBKDF2 to fit
|
|
37
|
+
* the runtime — `ownerAuth.ts` argues at length for argon2id over a fast KDF, and
|
|
38
|
+
* a port that quietly swapped it would have thrown that argument away for a
|
|
39
|
+
* platform detail. Same PHC string, same parameters, same answer.
|
|
40
|
+
*
|
|
41
|
+
* ## Why a GLOBAL shim rather than an injected verifier
|
|
42
|
+
*
|
|
43
|
+
* `Bun.password` is reached from three files (`auth.ts`, `ownerAuth.ts`,
|
|
44
|
+
* `cursedbelt-server/password`), and `Bun.` is a *global property access*, not an import — so
|
|
45
|
+
* a Worker bundle containing it builds fine and throws `Bun is not defined` only
|
|
46
|
+
* when a login happens. Threading a verifier through three signatures would be
|
|
47
|
+
* three more places for the Mac and the edge to disagree. Installing the global
|
|
48
|
+
* instead keeps the auth tier byte-identical on both runtimes, which is the same
|
|
49
|
+
* argument the apps' `store.ts` make for the async database seam.
|
|
50
|
+
*
|
|
51
|
+
* Used by `collections`, `vault` and `patterns` (each app's `worker/index.ts` calls
|
|
52
|
+
* {@link installBunPasswordShim} first thing). In `vault` this is ONLY the sign-in / lock
|
|
53
|
+
* verifier: the vault's ciphertext is derived and opened in the browser and never comes near it.
|
|
54
|
+
*/
|
|
55
|
+
import { constantTimeEqualBytes } from "cwip/constant-time";
|
|
56
|
+
import { argon2 } from "./argon2.js";
|
|
57
|
+
/** What Bun's `argon2id` default writes, so a hash minted here matches one minted there. */
|
|
58
|
+
const DEFAULT_MEMORY_KIB = 65_536;
|
|
59
|
+
const DEFAULT_ITERATIONS = 2;
|
|
60
|
+
const DEFAULT_PARALLELISM = 1;
|
|
61
|
+
const HASH_BYTES = 32;
|
|
62
|
+
// 32, matching what `Bun.password.hash` actually writes — measured 2026-09-18.
|
|
63
|
+
const SALT_BYTES = 32;
|
|
64
|
+
/**
|
|
65
|
+
* argon2's PHC encoding is base64 **without padding**, and with the standard
|
|
66
|
+
* alphabet rather than base64url. Getting either wrong yields a salt that decodes
|
|
67
|
+
* to the wrong bytes and a verification that fails for a correct password.
|
|
68
|
+
*/
|
|
69
|
+
const b64decode = (value) => {
|
|
70
|
+
const padded = value + "===".slice((value.length + 3) % 4);
|
|
71
|
+
return Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
|
|
72
|
+
};
|
|
73
|
+
const b64encode = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/=+$/, "");
|
|
74
|
+
/** `$argon2id$v=19$m=65536,t=2,p=1$<salt>$<hash>` → its parts, or null. */
|
|
75
|
+
export function parsePhc(encoded) {
|
|
76
|
+
const parts = encoded.split("$");
|
|
77
|
+
// ["", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash]
|
|
78
|
+
if (parts.length !== 6 || parts[1] !== "argon2id")
|
|
79
|
+
return null;
|
|
80
|
+
const params = new Map(parts[3].split(",").map((pair) => {
|
|
81
|
+
const [key, value] = pair.split("=");
|
|
82
|
+
return [key, Number(value)];
|
|
83
|
+
}));
|
|
84
|
+
const m = params.get("m");
|
|
85
|
+
const t = params.get("t");
|
|
86
|
+
const p = params.get("p");
|
|
87
|
+
if (!m || !t || !p)
|
|
88
|
+
return null;
|
|
89
|
+
try {
|
|
90
|
+
return { m, t, p, salt: b64decode(parts[4]), hash: b64decode(parts[5]) };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export const workerPassword = {
|
|
97
|
+
async verify(password, encoded) {
|
|
98
|
+
if (!password || !encoded)
|
|
99
|
+
return false;
|
|
100
|
+
try {
|
|
101
|
+
const phc = parsePhc(encoded);
|
|
102
|
+
if (!phc)
|
|
103
|
+
return false;
|
|
104
|
+
const derive = await argon2();
|
|
105
|
+
const derived = derive({
|
|
106
|
+
password: new TextEncoder().encode(password),
|
|
107
|
+
salt: phc.salt,
|
|
108
|
+
// 🔴 The STORED parameters, never this file's defaults. A hash written
|
|
109
|
+
// under a different cost must still verify, or rotating the cost locks
|
|
110
|
+
// the owner out of their own app.
|
|
111
|
+
passes: phc.t,
|
|
112
|
+
memorySize: phc.m,
|
|
113
|
+
parallelism: phc.p,
|
|
114
|
+
tagLength: phc.hash.length,
|
|
115
|
+
});
|
|
116
|
+
// Through the fleet's one constant-time primitive, exactly as
|
|
117
|
+
// `sync/tokens.ts` compares digests. A `===` here is a timing oracle on the
|
|
118
|
+
// one comparison in the app where it would matter.
|
|
119
|
+
return constantTimeEqualBytes(derived, phc.hash);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// A malformed stored hash must fail CLOSED, exactly as `auth.ts` does on
|
|
123
|
+
// Bun. Returning `false` rather than throwing keeps the refusal a phrase
|
|
124
|
+
// instead of a 500 that tells a caller the hash is broken.
|
|
125
|
+
//
|
|
126
|
+
// 🔴 But it is LOGGED, because a silent `false` here is indistinguishable
|
|
127
|
+
// from a wrong password — and on 2026-09-18 that cost a diagnosis: a
|
|
128
|
+
// working credential was refused and the only line anywhere said "invalid
|
|
129
|
+
// email or password". Never the password, never the hash; the error only.
|
|
130
|
+
console.error(`[cursedbelt-server/worker-argon2] argon2 verification threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
async hash(password, options = {}) {
|
|
135
|
+
const salt = new Uint8Array(SALT_BYTES);
|
|
136
|
+
crypto.getRandomValues(salt);
|
|
137
|
+
// `memoryCost`/`timeCost` are Bun's names; `cursedbelt-server/password` passes exactly
|
|
138
|
+
// those two, and its test profile passes much smaller ones.
|
|
139
|
+
const m = options.memoryCost ?? DEFAULT_MEMORY_KIB;
|
|
140
|
+
const t = options.timeCost ?? DEFAULT_ITERATIONS;
|
|
141
|
+
const derive = await argon2();
|
|
142
|
+
const derived = derive({
|
|
143
|
+
password: new TextEncoder().encode(password),
|
|
144
|
+
salt,
|
|
145
|
+
passes: t,
|
|
146
|
+
memorySize: m,
|
|
147
|
+
parallelism: DEFAULT_PARALLELISM,
|
|
148
|
+
tagLength: HASH_BYTES,
|
|
149
|
+
});
|
|
150
|
+
return `$argon2id$v=19$m=${m},t=${t},p=${DEFAULT_PARALLELISM}$${b64encode(salt)}$${b64encode(derived)}`;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Install the shim, once, before any route can run.
|
|
155
|
+
*
|
|
156
|
+
* Idempotent, and it never overwrites a real `Bun` — so importing this module in a
|
|
157
|
+
* Bun test does nothing, which is what keeps the gate honest about the Mac.
|
|
158
|
+
*/
|
|
159
|
+
export function installBunPasswordShim() {
|
|
160
|
+
const existing = globalThis.Bun;
|
|
161
|
+
if (existing)
|
|
162
|
+
return;
|
|
163
|
+
globalThis.Bun = { password: workerPassword };
|
|
164
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.24.1",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -215,6 +215,13 @@
|
|
|
215
215
|
"source": "./src/server/auth/passwordCost.ts",
|
|
216
216
|
"import": "./dist/server/auth/passwordCost.js"
|
|
217
217
|
},
|
|
218
|
+
"./worker-argon2": {
|
|
219
|
+
"types": "./dist/server/worker-argon2/index.d.ts",
|
|
220
|
+
"bun": "./src/server/worker-argon2/index.ts",
|
|
221
|
+
"source": "./src/server/worker-argon2/index.ts",
|
|
222
|
+
"node": "./dist/server/worker-argon2/_nodeRefuses.js",
|
|
223
|
+
"import": "./dist/server/worker-argon2/index.js"
|
|
224
|
+
},
|
|
218
225
|
"./request-log": {
|
|
219
226
|
"types": "./dist/server/requestLog/requestMetrics.d.ts",
|
|
220
227
|
"bun": "./src/server/requestLog/requestMetrics.ts",
|
|
@@ -301,6 +308,7 @@
|
|
|
301
308
|
}
|
|
302
309
|
},
|
|
303
310
|
"dependencies": {
|
|
311
|
+
"argon2id": "1.0.1",
|
|
304
312
|
"cursedbelt-core": "^2.1.1",
|
|
305
313
|
"cursedops": "^0.6.0",
|
|
306
314
|
"cwip": "^4.6.0",
|
|
@@ -43,6 +43,37 @@ describe('🔴 cursedbelt-server/password — a Worker can import it (task 2129)
|
|
|
43
43
|
});
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
describe('🔴 cursedbelt-server/worker-argon2 — it exists to be imported by a Worker', () => {
|
|
47
|
+
test('reaches no `bun:` module at runtime; `Bun.file` is touched only on the Bun-only path branch', () => {
|
|
48
|
+
const { files, bunImports } = runtimeGraph(join(import.meta.dir, 'worker-argon2', 'index.ts'));
|
|
49
|
+
expect(bunImports).toEqual([]);
|
|
50
|
+
// Not vacuous: the loader and the shim are both in the graph.
|
|
51
|
+
expect(files.some((f) => f.endsWith('worker-argon2/argon2.ts'))).toBe(true);
|
|
52
|
+
expect(files.some((f) => f.endsWith('worker-argon2/password.ts'))).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("its export map's `import` is the real module, not the Bun-only refusal", () => {
|
|
56
|
+
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf8')) as {
|
|
57
|
+
exports: Record<string, { import?: string }>;
|
|
58
|
+
};
|
|
59
|
+
expect(pkg.exports['./worker-argon2']?.import).toBe('./dist/server/worker-argon2/index.js');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('Node is refused by name — its WebAssembly ESM integration cannot load the static `.wasm` imports', async () => {
|
|
63
|
+
// 4.24.0 published with no `node` condition and failed proveInstall: node read the `.wasm`
|
|
64
|
+
// as an ES module and died on `Cannot find package 'env'`. wrangler never activates `node`
|
|
65
|
+
// (a dry-run bundle of the probe Worker carries both `.wasm` modules), so this costs a Worker nothing.
|
|
66
|
+
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf8')) as {
|
|
67
|
+
exports: Record<string, Record<string, string>>;
|
|
68
|
+
};
|
|
69
|
+
const conditions = Object.keys(pkg.exports['./worker-argon2'] ?? {});
|
|
70
|
+
expect(pkg.exports['./worker-argon2']?.node).toBe('./dist/server/worker-argon2/_nodeRefuses.js');
|
|
71
|
+
expect(conditions.indexOf('node')).toBeLessThan(conditions.indexOf('import'));
|
|
72
|
+
expect(conditions.indexOf('bun')).toBeLessThan(conditions.indexOf('node'));
|
|
73
|
+
await expect(import('./worker-argon2/_nodeRefuses.ts')).rejects.toThrow('not Node');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
46
77
|
describe('cursedbelt-server/telemetry', () => {
|
|
47
78
|
test('🔴 reaches no `bun:` module at runtime — a Worker can import it', () => {
|
|
48
79
|
const { files, bunImports } = runtimeGraph(join(import.meta.dir, 'telemetry.ts'));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Loaded only under Node — the `node` condition of `cursedbelt-server/worker-argon2`.
|
|
2
|
+
//
|
|
3
|
+
// This subpath's two `.wasm` files are STATIC imports, which is the one shape workerd will run
|
|
4
|
+
// (`./argon2.ts`). Node 24 reads the same import through its WebAssembly ESM integration and
|
|
5
|
+
// instantiates the module itself, resolving its imports from a package called `env` — so the
|
|
6
|
+
// real module fails under Node with `ERR_MODULE_NOT_FOUND: Cannot find package 'env'`, deep in
|
|
7
|
+
// `argon2id/dist/no-simd.wasm` (measured by tools/proveInstall.ts publishing 4.24.0, 2026-09-23).
|
|
8
|
+
// Nothing in this generation runs it on Node: a Worker gets it through wrangler (`workerd`
|
|
9
|
+
// resolves `import`), a Mac server through Bun (`bun`), and a Mac server already HAS
|
|
10
|
+
// `Bun.password`. So Node gets this sentence instead. Same shape as `../_bunOnly.ts`.
|
|
11
|
+
throw new Error(
|
|
12
|
+
"cursedbelt-server/worker-argon2 runs on workerd (through wrangler) and Bun — not Node, which cannot load its static `.wasm` imports. " +
|
|
13
|
+
"A Node server should hash with a native argon2 (`@node-rs/argon2`).",
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* argon2id as WebAssembly the Worker was HANDED, never WebAssembly it compiles.
|
|
3
|
+
*
|
|
4
|
+
* ## 🔴 Why this file exists: one unlock was 8.5–15 seconds of billed CPU
|
|
5
|
+
*
|
|
6
|
+
* `collections` averaged **489 CPU-ms per request on 2026-09-22 with a 15.2 s p99** (Cloudflare
|
|
7
|
+
* GraphQL, `workersInvocationsAdaptive`). Every second of it was `POST /__lock/unlock`: a real
|
|
8
|
+
* `wrangler tail` of the stage walk on 2026-09-23 read **8,519 CPU-ms and 10.4 s wall** for that
|
|
9
|
+
* one request and 10–38 ms for each of the other eleven. The lock verifies every account's
|
|
10
|
+
* argon2id hash (no early exit — `cursedbelt-server/master-lock` explains the timing oracle) plus
|
|
11
|
+
* a timing equalizer, so production's two accounts are three to four derivations per unlock, and
|
|
12
|
+
* `@noble/hashes`' pure-JS argon2id costs ~2.8 s each on workerd — five times the 562 ms it
|
|
13
|
+
* costs on this Mac. The owner waited ten seconds at the lock page, and a third account would
|
|
14
|
+
* have put the unlock within reach of the Worker's 30 s CPU ceiling, where it is killed and
|
|
15
|
+
* reads as a wrong password.
|
|
16
|
+
*
|
|
17
|
+
* ## 🔴 Why this shape: workerd refuses to COMPILE WebAssembly, not to RUN it
|
|
18
|
+
*
|
|
19
|
+
* `hash-wasm` failed here on 2026-09-18 with `Wasm code generation disallowed by embedder`
|
|
20
|
+
* because it compiles bytes it carries inline (`./password.ts` has the whole story). A
|
|
21
|
+
* `.wasm` file that is a STATIC import is different: wrangler bundles it as a
|
|
22
|
+
* `CompiledWasm` module and the runtime hands this code a ready `WebAssembly.Module`, which
|
|
23
|
+
* `WebAssembly.instantiate(module, imports)` may instantiate. `argon2id` (openpgpjs, MIT) ships
|
|
24
|
+
* its two binaries as plain files and lets the caller do the instantiating, which is exactly the
|
|
25
|
+
* seam a Worker needs. Its default entry inlines base64 and compiles it — so it is never imported.
|
|
26
|
+
*
|
|
27
|
+
* Under Bun (every test, and the Mac) the same import is a PATH STRING, so the bytes are read
|
|
28
|
+
* and compiled here — which Bun allows. `index.spec.ts` drives the Worker's branch too, with
|
|
29
|
+
* `WebAssembly.compile` made to throw exactly as workerd throws.
|
|
30
|
+
*
|
|
31
|
+
* ## Why it lives in `cursedbelt-server` (4.24.0)
|
|
32
|
+
*
|
|
33
|
+
* It was born in `apps/collections/worker/argon2.ts` (commit 7996ded) while `apps/vault` and
|
|
34
|
+
* `apps/patterns` still carried the pure-JS copy, so every sign-in there billed seconds. One copy
|
|
35
|
+
* here, `argon2id` a pinned dependency of this package: its two `.wasm` files ship in its own
|
|
36
|
+
* `files`, and wrangler — which resolves this subpath's `import` condition, `dist/…/argon2.js`,
|
|
37
|
+
* where tsc leaves the bare `argon2id/dist/*.wasm` specifiers untouched — bundles them from
|
|
38
|
+
* `node_modules` as `CompiledWasm`. `index.spec.ts` asserts `dist` still carries them that way.
|
|
39
|
+
*/
|
|
40
|
+
import noSimdWasm from "argon2id/dist/no-simd.wasm";
|
|
41
|
+
import simdWasm from "argon2id/dist/simd.wasm";
|
|
42
|
+
import setupWasm, { type computeHash } from "argon2id/lib/setup.js";
|
|
43
|
+
|
|
44
|
+
/** What a `.wasm` import is: a compiled module under wrangler, a file path under Bun. */
|
|
45
|
+
export type WasmImport = WebAssembly.Module | string;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Turn one `.wasm` import into an instance. 🔴 A `Module` is instantiated and NEVER compiled —
|
|
49
|
+
* that is the whole workerd rule — and only a path (Bun) is read and compiled.
|
|
50
|
+
*/
|
|
51
|
+
export async function instantiateWasm(
|
|
52
|
+
source: WasmImport,
|
|
53
|
+
imports: WebAssembly.Imports,
|
|
54
|
+
): Promise<WebAssembly.WebAssemblyInstantiatedSource> {
|
|
55
|
+
const module =
|
|
56
|
+
typeof source === "string" ? await WebAssembly.compile(await Bun.file(source).arrayBuffer()) : source;
|
|
57
|
+
const instance = await WebAssembly.instantiate(module, imports);
|
|
58
|
+
return { module, instance };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build a hasher from the two binaries — SIMD first, the plain one if SIMD will not load. */
|
|
62
|
+
export function loadArgon2(simd: WasmImport = simdWasm, noSimd: WasmImport = noSimdWasm): Promise<computeHash> {
|
|
63
|
+
return setupWasm(
|
|
64
|
+
(imports) => instantiateWasm(simd, imports),
|
|
65
|
+
(imports) => instantiateWasm(noSimd, imports),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/*
|
|
70
|
+
* 🔴 ONE hasher per isolate, built on first use. Not request state — it holds no password and no
|
|
71
|
+
* result between calls (the library clears its memory after each) — so this is the one kind of
|
|
72
|
+
* module-scope value a Worker may keep. The reason to keep it is MEMORY: each
|
|
73
|
+
* instance owns a 65 MB `WebAssembly.Memory` that can never shrink, and an unlock runs three or
|
|
74
|
+
* four derivations back to back; one per call would stack up to four of them against a 128 MB
|
|
75
|
+
* isolate before a collector ran. A load that FAILS is not cached, so the next unlock retries.
|
|
76
|
+
*/
|
|
77
|
+
let shared: Promise<computeHash> | null = null;
|
|
78
|
+
|
|
79
|
+
export function argon2(): Promise<computeHash> {
|
|
80
|
+
shared ??= loadArgon2().catch((error: unknown) => {
|
|
81
|
+
shared = null;
|
|
82
|
+
throw error;
|
|
83
|
+
});
|
|
84
|
+
return shared;
|
|
85
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/worker-argon2`: the Worker's argon2id loads the way workerd lets it, and
|
|
3
|
+
* agrees with `Bun.password` byte for byte.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 The branch that matters cannot run here by accident: under Bun a `.wasm` import is a file
|
|
6
|
+
* PATH, so `argon2()` in this process compiles — which workerd forbids. These cases hand the
|
|
7
|
+
* loader what wrangler hands it (a compiled `WebAssembly.Module`) with `WebAssembly.compile`
|
|
8
|
+
* rigged to throw workerd's own words, so a loader that compiled anyway goes red here rather
|
|
9
|
+
* than at an owner's lock page (`./password.ts`, the 2026-09-18 `hash-wasm` deploy).
|
|
10
|
+
*
|
|
11
|
+
* The shim is driven DIRECTLY rather than through `installBunPasswordShim`: the shim does nothing
|
|
12
|
+
* when a real `Bun` exists, so testing through it here would test Bun and report on the Worker.
|
|
13
|
+
* Lifted from `apps/collections/worker/{argon2,password}.test.ts` (commit 7996ded).
|
|
14
|
+
*/
|
|
15
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import noSimdPath from "argon2id/dist/no-simd.wasm";
|
|
19
|
+
import simdPath from "argon2id/dist/simd.wasm";
|
|
20
|
+
import { argon2, installBunPasswordShim, instantiateWasm, loadArgon2, parsePhc, workerPassword } from "./index.js";
|
|
21
|
+
|
|
22
|
+
const WORKERD_REFUSAL = "WebAssembly.compile(): Wasm code generation disallowed by embedder";
|
|
23
|
+
const realCompile = WebAssembly.compile;
|
|
24
|
+
|
|
25
|
+
/** workerd's rule, reproduced: instantiating a module is allowed, compiling bytes is not. */
|
|
26
|
+
function forbidCompile(): void {
|
|
27
|
+
WebAssembly.compile = (() => Promise.reject(new Error(WORKERD_REFUSAL))) as typeof WebAssembly.compile;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
WebAssembly.compile = realCompile;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** What wrangler's `CompiledWasm` rule hands the bundle, compiled BEFORE the rule is imposed. */
|
|
35
|
+
async function asWranglerHandsIt(path: string | WebAssembly.Module): Promise<WebAssembly.Module> {
|
|
36
|
+
return typeof path === "string" ? realCompile(await Bun.file(path).arrayBuffer()) : path;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PASSWORD = "the tide remembers every shore";
|
|
40
|
+
const hex = (bytes: Uint8Array): string => Buffer.from(bytes).toString("hex");
|
|
41
|
+
|
|
42
|
+
describe("argon2id on the Worker", () => {
|
|
43
|
+
it("🔴 derives from a compiled Module with compilation forbidden — byte-identical to Bun.password", async () => {
|
|
44
|
+
const simd = await asWranglerHandsIt(simdPath);
|
|
45
|
+
const noSimd = await asWranglerHandsIt(noSimdPath);
|
|
46
|
+
// Bun's DEFAULT argon2id (m=64 MiB, t=2) — what every production hash in the fleet carries —
|
|
47
|
+
// and a cheap profile, so the stored parameters are proven to be the ones used.
|
|
48
|
+
const stored = [
|
|
49
|
+
await Bun.password.hash(PASSWORD, { algorithm: "argon2id" }),
|
|
50
|
+
await Bun.password.hash(PASSWORD, { algorithm: "argon2id", memoryCost: 4096, timeCost: 1 }),
|
|
51
|
+
];
|
|
52
|
+
forbidCompile();
|
|
53
|
+
const derive = await loadArgon2(simd, noSimd);
|
|
54
|
+
for (const phcString of stored) {
|
|
55
|
+
const phc = parsePhc(phcString);
|
|
56
|
+
if (!phc) throw new Error(`Bun wrote a hash this module cannot parse: ${phcString}`);
|
|
57
|
+
const derived = derive({
|
|
58
|
+
password: new TextEncoder().encode(PASSWORD),
|
|
59
|
+
salt: phc.salt,
|
|
60
|
+
passes: phc.t,
|
|
61
|
+
memorySize: phc.m,
|
|
62
|
+
parallelism: phc.p,
|
|
63
|
+
tagLength: phc.hash.length,
|
|
64
|
+
});
|
|
65
|
+
expect(hex(derived)).toBe(hex(phc.hash));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("🔴 a loader that COMPILES goes red under the same rule — the check can fail", async () => {
|
|
70
|
+
forbidCompile();
|
|
71
|
+
await expect(
|
|
72
|
+
instantiateWasm(simdPath, { env: { memory: new WebAssembly.Memory({ initial: 1 }) } }),
|
|
73
|
+
).rejects.toThrow(WORKERD_REFUSAL);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps ONE hasher per isolate — a 65 MB memory per call would stack against 128 MB", async () => {
|
|
77
|
+
expect(await argon2()).toBe(await argon2());
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("is several times cheaper than the pure-JS derivation it replaced, at the owner's real cost", async () => {
|
|
81
|
+
const derive = await argon2();
|
|
82
|
+
const started = process.cpuUsage();
|
|
83
|
+
derive({
|
|
84
|
+
password: new TextEncoder().encode(PASSWORD),
|
|
85
|
+
salt: new Uint8Array(32).fill(7),
|
|
86
|
+
passes: 2,
|
|
87
|
+
memorySize: 65_536,
|
|
88
|
+
parallelism: 1,
|
|
89
|
+
tagLength: 32,
|
|
90
|
+
});
|
|
91
|
+
// CPU, not wall clock, so a loaded gate does not stretch it: WASM measured ~70–180 CPU-ms
|
|
92
|
+
// on this Mac, `@noble/hashes`' pure JS ~530–600 (and ~2.8 s on workerd). The ceiling sits
|
|
93
|
+
// between them.
|
|
94
|
+
const spent = process.cpuUsage(started);
|
|
95
|
+
expect((spent.user + spent.system) / 1000).toBeLessThan(400);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("the Bun.password shim", () => {
|
|
100
|
+
it("verifies a hash Bun wrote — the whole reason the owner keeps their password", async () => {
|
|
101
|
+
const hash = await Bun.password.hash(PASSWORD, { algorithm: "argon2id" });
|
|
102
|
+
expect(await workerPassword.verify(PASSWORD, hash)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("refuses a wrong password against a hash Bun wrote", async () => {
|
|
106
|
+
const hash = await Bun.password.hash(PASSWORD, { algorithm: "argon2id" });
|
|
107
|
+
expect(await workerPassword.verify("not the password", hash)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("writes a hash BUN can verify, so a password changed on the edge still works on the Mac", async () => {
|
|
111
|
+
const hash = await workerPassword.hash(PASSWORD, { memoryCost: 4096, timeCost: 1 });
|
|
112
|
+
expect(hash.startsWith("$argon2id$v=19$m=4096,t=1,p=1$")).toBe(true);
|
|
113
|
+
expect(await Bun.password.verify(PASSWORD, hash)).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("honours the STORED cost parameters, not its own defaults", async () => {
|
|
117
|
+
const cheap = await Bun.password.hash(PASSWORD, { algorithm: "argon2id", memoryCost: 4096, timeCost: 1 });
|
|
118
|
+
expect(parsePhc(cheap)?.m).toBe(4096);
|
|
119
|
+
expect(await workerPassword.verify(PASSWORD, cheap)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("fails closed on junk rather than throwing", async () => {
|
|
123
|
+
for (const junk of ["", "not-a-hash", "$argon2id$", "$argon2i$v=19$m=1,t=1,p=1$AA$AA"]) {
|
|
124
|
+
expect(await workerPassword.verify(PASSWORD, junk)).toBe(false);
|
|
125
|
+
}
|
|
126
|
+
expect(await workerPassword.verify("", "$argon2id$v=19$m=4096,t=1,p=1$AA$AA")).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("decodes unpadded PHC base64, which is where a salt silently goes wrong", async () => {
|
|
130
|
+
const parsed = parsePhc(await Bun.password.hash(PASSWORD, { algorithm: "argon2id" }));
|
|
131
|
+
// Bun's default salt and digest are both 32 bytes — measured, not assumed.
|
|
132
|
+
expect(parsed?.salt.length).toBe(32);
|
|
133
|
+
expect(parsed?.hash.length).toBe(32);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("never overwrites a real Bun — so installing it in this process is a no-op", () => {
|
|
137
|
+
const before = Bun.password;
|
|
138
|
+
installBunPasswordShim();
|
|
139
|
+
expect(Bun.password).toBe(before);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("🔴 what wrangler bundles", () => {
|
|
144
|
+
const dist = join(import.meta.dir, "..", "..", "..", "dist", "server", "worker-argon2", "argon2.js");
|
|
145
|
+
|
|
146
|
+
it("dist keeps both .wasm files as STATIC imports — the only shape workerd will run", () => {
|
|
147
|
+
// wrangler resolves this subpath's `import` condition, i.e. this file. A build that inlined
|
|
148
|
+
// or dynamically loaded the bytes would deploy fine and refuse every sign-in.
|
|
149
|
+
const built = readFileSync(dist, "utf8");
|
|
150
|
+
expect(built).toMatch(/^import \w+ from ["']argon2id\/dist\/simd\.wasm["'];?$/m);
|
|
151
|
+
expect(built).toMatch(/^import \w+ from ["']argon2id\/dist\/no-simd\.wasm["'];?$/m);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("never imports argon2id's default entry, which compiles inline base64", () => {
|
|
155
|
+
for (const file of ["argon2.ts", "password.ts", "index.ts"]) {
|
|
156
|
+
const source = readFileSync(join(import.meta.dir, file), "utf8");
|
|
157
|
+
expect(source).not.toMatch(/from ["']argon2id["']/);
|
|
158
|
+
}
|
|
159
|
+
const built = readFileSync(dist, "utf8");
|
|
160
|
+
expect(built).not.toMatch(/from ["']argon2id["']/);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/worker-argon2` — argon2id a Cloudflare Worker can afford, and the
|
|
3
|
+
* `Bun.password` shim that lets the same auth code verify on the Mac and on the edge.
|
|
4
|
+
*
|
|
5
|
+
* `./argon2.ts` is the WHY (8.5 s of billed CPU per unlock in pure JS; workerd forbids compiling
|
|
6
|
+
* WebAssembly, so the `.wasm` is a static import wrangler bundles); `./password.ts` is the shim.
|
|
7
|
+
*/
|
|
8
|
+
export { argon2, instantiateWasm, loadArgon2, type WasmImport } from "./argon2.js";
|
|
9
|
+
export { type BunPasswordShim, installBunPasswordShim, parsePhc, workerPassword } from "./password.js";
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* argon2id on `workerd`, where the obvious answer does not work.
|
|
3
|
+
*
|
|
4
|
+
* ## 🔴 The finding, and it applies to every app in this fleet
|
|
5
|
+
*
|
|
6
|
+
* **A Worker may not compile WebAssembly at runtime.** `hash-wasm` — the first and
|
|
7
|
+
* most natural choice, and the one every search result recommends — inlines its
|
|
8
|
+
* argon2 module as base64 and calls `WebAssembly.compile(bytes)` on first use.
|
|
9
|
+
* Deployed here on 2026-09-18, that produced:
|
|
10
|
+
*
|
|
11
|
+
* ```
|
|
12
|
+
* [patterns] argon2 verification threw: ← the pilot's log line, quoted verbatim
|
|
13
|
+
* WebAssembly.compile(): Wasm code generation disallowed by embedder
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* It **builds, deploys and starts perfectly**, and fails only when somebody tries
|
|
17
|
+
* to sign in — where it is indistinguishable from a wrong password, because a KDF
|
|
18
|
+
* that throws must fail closed. That combination is the worst shape a defect can
|
|
19
|
+
* have, and it is why `verify` below logs the error: a silent `false` cost a
|
|
20
|
+
* diagnosis on the day this was written. On Workers, WASM must be a *static
|
|
21
|
+
* import* of a `.wasm` file that wrangler bundles as a module; a library that
|
|
22
|
+
* carries its bytes inline can never satisfy that.
|
|
23
|
+
*
|
|
24
|
+
* So this runs **argon2id as WebAssembly that wrangler bundles as a static module** —
|
|
25
|
+
* `./argon2.ts`, which instantiates a `WebAssembly.Module` it was handed and never compiles
|
|
26
|
+
* one. Until 2026-09-23 it was `@noble/hashes/argon2`, pure JavaScript: byte-identical output
|
|
27
|
+
* and 562 ms per verification on this Mac, which read as acceptable — and on workerd it was
|
|
28
|
+
* **~2.8 s per derivation**, so one unlock (every account's hash plus the timing equalizer)
|
|
29
|
+
* billed 8.5 s on the stage and 15 s in production, and was 489 CPU-ms averaged over every
|
|
30
|
+
* request the app served on 2026-09-22. `./argon2.ts` carries the measurement.
|
|
31
|
+
* `cursedbelt-server/login-throttle` is what holds the line against a flood, not the KDF's cost.
|
|
32
|
+
*
|
|
33
|
+
* ## What this buys, and it is the reason to pay for argon2id at all
|
|
34
|
+
*
|
|
35
|
+
* **The owner's existing password hash works on a Worker unchanged.** No re-hash,
|
|
36
|
+
* no "sign in again on the new host", and above all no downgrade to PBKDF2 to fit
|
|
37
|
+
* the runtime — `ownerAuth.ts` argues at length for argon2id over a fast KDF, and
|
|
38
|
+
* a port that quietly swapped it would have thrown that argument away for a
|
|
39
|
+
* platform detail. Same PHC string, same parameters, same answer.
|
|
40
|
+
*
|
|
41
|
+
* ## Why a GLOBAL shim rather than an injected verifier
|
|
42
|
+
*
|
|
43
|
+
* `Bun.password` is reached from three files (`auth.ts`, `ownerAuth.ts`,
|
|
44
|
+
* `cursedbelt-server/password`), and `Bun.` is a *global property access*, not an import — so
|
|
45
|
+
* a Worker bundle containing it builds fine and throws `Bun is not defined` only
|
|
46
|
+
* when a login happens. Threading a verifier through three signatures would be
|
|
47
|
+
* three more places for the Mac and the edge to disagree. Installing the global
|
|
48
|
+
* instead keeps the auth tier byte-identical on both runtimes, which is the same
|
|
49
|
+
* argument the apps' `store.ts` make for the async database seam.
|
|
50
|
+
*
|
|
51
|
+
* Used by `collections`, `vault` and `patterns` (each app's `worker/index.ts` calls
|
|
52
|
+
* {@link installBunPasswordShim} first thing). In `vault` this is ONLY the sign-in / lock
|
|
53
|
+
* verifier: the vault's ciphertext is derived and opened in the browser and never comes near it.
|
|
54
|
+
*/
|
|
55
|
+
import { constantTimeEqualBytes } from "cwip/constant-time";
|
|
56
|
+
import { argon2 } from "./argon2.js";
|
|
57
|
+
|
|
58
|
+
/** What Bun's `argon2id` default writes, so a hash minted here matches one minted there. */
|
|
59
|
+
const DEFAULT_MEMORY_KIB = 65_536;
|
|
60
|
+
const DEFAULT_ITERATIONS = 2;
|
|
61
|
+
const DEFAULT_PARALLELISM = 1;
|
|
62
|
+
const HASH_BYTES = 32;
|
|
63
|
+
// 32, matching what `Bun.password.hash` actually writes — measured 2026-09-18.
|
|
64
|
+
const SALT_BYTES = 32;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* argon2's PHC encoding is base64 **without padding**, and with the standard
|
|
68
|
+
* alphabet rather than base64url. Getting either wrong yields a salt that decodes
|
|
69
|
+
* to the wrong bytes and a verification that fails for a correct password.
|
|
70
|
+
*/
|
|
71
|
+
const b64decode = (value: string): Uint8Array => {
|
|
72
|
+
const padded = value + "===".slice((value.length + 3) % 4);
|
|
73
|
+
return Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const b64encode = (bytes: Uint8Array): string =>
|
|
77
|
+
btoa(String.fromCharCode(...bytes)).replace(/=+$/, "");
|
|
78
|
+
|
|
79
|
+
interface Phc {
|
|
80
|
+
m: number;
|
|
81
|
+
t: number;
|
|
82
|
+
p: number;
|
|
83
|
+
salt: Uint8Array;
|
|
84
|
+
hash: Uint8Array;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `$argon2id$v=19$m=65536,t=2,p=1$<salt>$<hash>` → its parts, or null. */
|
|
88
|
+
export function parsePhc(encoded: string): Phc | null {
|
|
89
|
+
const parts = encoded.split("$");
|
|
90
|
+
// ["", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash]
|
|
91
|
+
if (parts.length !== 6 || parts[1] !== "argon2id") return null;
|
|
92
|
+
const params = new Map(
|
|
93
|
+
(parts[3] as string).split(",").map((pair) => {
|
|
94
|
+
const [key, value] = pair.split("=");
|
|
95
|
+
return [key as string, Number(value)] as const;
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
const m = params.get("m");
|
|
99
|
+
const t = params.get("t");
|
|
100
|
+
const p = params.get("p");
|
|
101
|
+
if (!m || !t || !p) return null;
|
|
102
|
+
try {
|
|
103
|
+
return { m, t, p, salt: b64decode(parts[4] as string), hash: b64decode(parts[5] as string) };
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface BunPasswordShim {
|
|
110
|
+
verify(password: string, hash: string): Promise<boolean>;
|
|
111
|
+
hash(
|
|
112
|
+
password: string,
|
|
113
|
+
options?: { algorithm?: string; memoryCost?: number; timeCost?: number },
|
|
114
|
+
): Promise<string>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const workerPassword: BunPasswordShim = {
|
|
118
|
+
async verify(password, encoded) {
|
|
119
|
+
if (!password || !encoded) return false;
|
|
120
|
+
try {
|
|
121
|
+
const phc = parsePhc(encoded);
|
|
122
|
+
if (!phc) return false;
|
|
123
|
+
const derive = await argon2();
|
|
124
|
+
const derived = derive({
|
|
125
|
+
password: new TextEncoder().encode(password),
|
|
126
|
+
salt: phc.salt,
|
|
127
|
+
// 🔴 The STORED parameters, never this file's defaults. A hash written
|
|
128
|
+
// under a different cost must still verify, or rotating the cost locks
|
|
129
|
+
// the owner out of their own app.
|
|
130
|
+
passes: phc.t,
|
|
131
|
+
memorySize: phc.m,
|
|
132
|
+
parallelism: phc.p,
|
|
133
|
+
tagLength: phc.hash.length,
|
|
134
|
+
});
|
|
135
|
+
// Through the fleet's one constant-time primitive, exactly as
|
|
136
|
+
// `sync/tokens.ts` compares digests. A `===` here is a timing oracle on the
|
|
137
|
+
// one comparison in the app where it would matter.
|
|
138
|
+
return constantTimeEqualBytes(derived, phc.hash);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
// A malformed stored hash must fail CLOSED, exactly as `auth.ts` does on
|
|
141
|
+
// Bun. Returning `false` rather than throwing keeps the refusal a phrase
|
|
142
|
+
// instead of a 500 that tells a caller the hash is broken.
|
|
143
|
+
//
|
|
144
|
+
// 🔴 But it is LOGGED, because a silent `false` here is indistinguishable
|
|
145
|
+
// from a wrong password — and on 2026-09-18 that cost a diagnosis: a
|
|
146
|
+
// working credential was refused and the only line anywhere said "invalid
|
|
147
|
+
// email or password". Never the password, never the hash; the error only.
|
|
148
|
+
console.error(
|
|
149
|
+
`[cursedbelt-server/worker-argon2] argon2 verification threw: ${error instanceof Error ? error.message : String(error)}`,
|
|
150
|
+
);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async hash(password, options = {}) {
|
|
156
|
+
const salt = new Uint8Array(SALT_BYTES);
|
|
157
|
+
crypto.getRandomValues(salt);
|
|
158
|
+
// `memoryCost`/`timeCost` are Bun's names; `cursedbelt-server/password` passes exactly
|
|
159
|
+
// those two, and its test profile passes much smaller ones.
|
|
160
|
+
const m = options.memoryCost ?? DEFAULT_MEMORY_KIB;
|
|
161
|
+
const t = options.timeCost ?? DEFAULT_ITERATIONS;
|
|
162
|
+
const derive = await argon2();
|
|
163
|
+
const derived = derive({
|
|
164
|
+
password: new TextEncoder().encode(password),
|
|
165
|
+
salt,
|
|
166
|
+
passes: t,
|
|
167
|
+
memorySize: m,
|
|
168
|
+
parallelism: DEFAULT_PARALLELISM,
|
|
169
|
+
tagLength: HASH_BYTES,
|
|
170
|
+
});
|
|
171
|
+
return `$argon2id$v=19$m=${m},t=${t},p=${DEFAULT_PARALLELISM}$${b64encode(salt)}$${b64encode(derived)}`;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Install the shim, once, before any route can run.
|
|
177
|
+
*
|
|
178
|
+
* Idempotent, and it never overwrites a real `Bun` — so importing this module in a
|
|
179
|
+
* Bun test does nothing, which is what keeps the gate honest about the Mac.
|
|
180
|
+
*/
|
|
181
|
+
export function installBunPasswordShim(): void {
|
|
182
|
+
const existing = (globalThis as { Bun?: unknown }).Bun;
|
|
183
|
+
if (existing) return;
|
|
184
|
+
(globalThis as { Bun?: unknown }).Bun = { password: workerPassword };
|
|
185
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `.wasm` import, as the two runtimes that load this code spell it: wrangler bundles the file
|
|
3
|
+
* as a `CompiledWasm` module and hands over a `WebAssembly.Module`; Bun hands over the file's
|
|
4
|
+
* path. `./argon2.ts` accepts both, and is the only importer outside its spec.
|
|
5
|
+
*/
|
|
6
|
+
declare module "*.wasm" {
|
|
7
|
+
const source: WebAssembly.Module | string;
|
|
8
|
+
export default source;
|
|
9
|
+
}
|