cursedbelt-server 4.23.0 → 4.24.0

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 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,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.23.0",
3
+ "version": "4.24.0",
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,12 @@
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
+ "import": "./dist/server/worker-argon2/index.js"
223
+ },
218
224
  "./request-log": {
219
225
  "types": "./dist/server/requestLog/requestMetrics.d.ts",
220
226
  "bun": "./src/server/requestLog/requestMetrics.ts",
@@ -301,6 +307,7 @@
301
307
  }
302
308
  },
303
309
  "dependencies": {
310
+ "argon2id": "1.0.1",
304
311
  "cursedbelt-core": "^2.1.1",
305
312
  "cursedops": "^0.6.0",
306
313
  "cwip": "^4.6.0",
@@ -43,6 +43,23 @@ 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
+
46
63
  describe('cursedbelt-server/telemetry', () => {
47
64
  test('🔴 reaches no `bun:` module at runtime — a Worker can import it', () => {
48
65
  const { files, bunImports } = runtimeGraph(join(import.meta.dir, 'telemetry.ts'));
@@ -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
+ }