cursedbelt-server 4.22.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 +8 -0
- package/dist/server/bench/budget.d.ts +50 -0
- package/dist/server/bench/budget.js +49 -1
- package/dist/server/bench/index.d.ts +1 -1
- package/dist/server/bench/index.js +1 -1
- package/dist/server/metrics/metricsBuffer.d.ts +10 -0
- package/dist/server/metrics/metricsBuffer.js +11 -3
- package/dist/server/metrics/requestPlace.d.ts +48 -0
- package/dist/server/metrics/requestPlace.js +77 -0
- package/dist/server/metrics/telemetrySink.d.ts +6 -1
- package/dist/server/metrics/telemetrySink.js +14 -2
- package/dist/server/middleware/requestLogger.d.ts +8 -0
- package/dist/server/middleware/requestLogger.js +4 -1
- package/dist/server/requestLog/requestMetrics.d.ts +11 -2
- package/dist/server/requestLog/requestMetrics.js +22 -4
- package/dist/server/telemetry.d.ts +1 -0
- package/dist/server/telemetry.js +1 -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 +8 -1
- package/src/server/bench/budget.spec.ts +38 -0
- package/src/server/bench/budget.ts +58 -1
- package/src/server/bench/cpuBudget.spec.ts +15 -3
- package/src/server/bench/index.ts +1 -0
- package/src/server/metrics/metricsBuffer.ts +22 -2
- package/src/server/metrics/requestPlace.spec.ts +142 -0
- package/src/server/metrics/requestPlace.ts +92 -0
- package/src/server/metrics/telemetrySink.spec.ts +2 -1
- package/src/server/metrics/telemetrySink.ts +14 -2
- package/src/server/middleware/requestLogger.ts +12 -1
- package/src/server/requestLog/requestMetrics.spec.ts +73 -0
- package/src/server/requestLog/requestMetrics.ts +37 -4
- package/src/server/telemetry.ts +5 -0
- package/src/server/telemetryIsWorkerSafe.spec.ts +17 -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
|
@@ -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
|
+
}
|