Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,11 @@ FROM_NAME=Billet
# Retry-After value in seconds (default 3600).
# MAINTENANCE_MODE=true
# MAINTENANCE_RETRY_AFTER=3600

# Optional — set to "true" to add a proof-of-work captcha to the /login form,
# blocking automated spam sign-ups. Self-hosted with no third party or account: it
# signs challenges with CRYPTO_PEPPER, so no extra secret is needed. A honeypot and
# per-IP rate limit protect /login regardless of this flag. CAPTCHA_DIFFICULTY tunes
# the client's work (search space size; default 100000).
# CAPTCHA_ENABLED=true
# CAPTCHA_DIFFICULTY=100000
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ A complete magic-link email auth flow: users enter their email, receive a login

- **CSRF protection** using the synchronizer token pattern with timing-safe comparison and origin validation
- **Rate limiting** middleware with configurable sliding-window limits per IP
- **Signup spam defense** on the login form — an always-on honeypot and per-IP rate limit, plus an optional first-party proof-of-work captcha (no third party, account, or extra secret; off by default, enable with `CAPTCHA_ENABLED`)
- **Session fixation prevention** — sessions are regenerated on login
- **Environment validation** at startup — the server fails fast with clear error messages if required variables are missing
- **Response hardening** — security headers on every response (nosniff, frame/clickjacking protection, an enforcing Content Security Policy, Permissions-Policy, HSTS in production), plus `/.well-known/security.txt` and Subresource Integrity on third-party scripts — see [runbooks/SECURITY.md](runbooks/SECURITY.md)
Expand Down Expand Up @@ -281,6 +282,8 @@ A `railway.json` is included with build and start commands pre-configured. Deplo
| `CRYPTO_PEPPER` | Yes | Secret key for session tokens — run `bun run generate:pepper` to get one (see below) |
| `APP_URL` | Yes | Your app's public URL — you'll get this from Railway after your first deploy (e.g. `https://my-app.up.railway.app`) |
| `PORT` | No | Server port — auto-set by Railway, defaults to `3000` locally |
| `CAPTCHA_ENABLED` | No | Set to `true` to add a proof-of-work captcha to the login form. Off by default; `/login` is unchanged when unset |
| `CAPTCHA_DIFFICULTY` | No | Tunes the captcha's client-side work (search-space size). Defaults to `100000` (~sub-second for a real browser) |

> **Generating `CRYPTO_PEPPER`:** This is a secret key used to secure session tokens. Run `bun run generate:pepper` to get a value. Use a different value for each environment (development, production, etc).

Expand All @@ -290,6 +293,8 @@ A `railway.json` is included with build and start commands pre-configured. Deplo

> **Security:** The HTTP hardening (security headers, CSP, HSTS, SRI) works out of the box, but set `SECURITY_CONTACT` (the `security.txt` reporting address) and add the registrar-level records before launch — see [runbooks/SECURITY.md](runbooks/SECURITY.md) for that plus the TLS, HSTS-preload, CAA, and DNSSEC steps.

> **Signup spam:** The login form always carries a honeypot and per-IP rate limit. If bots still create accounts with random emails, set `CAPTCHA_ENABLED=true` to add a first-party proof-of-work captcha — it signs challenges with your existing `CRYPTO_PEPPER`, so there's no third party, account, or extra secret to configure.

> **Privacy:** The default site needs no cookie banner (zero non-essential storage). The moment you add analytics, ads, or embeds, you must add an opt-in consent banner and a privacy policy — see [runbooks/PRIVACY.md](runbooks/PRIVACY.md) for wiring up `@alexpricedev/billet-cookie-consent`, the required policy disclosures, and GPC handling.

> **CI & merge protection:** CI runs on every PR, but check results are advisory until you require them. A fork doesn't inherit branch protection, so nothing stops an auto-merge (GitHub's or Conductor's) from merging a red build — enable required status checks once per repo. See [runbooks/CI.md](runbooks/CI.md).
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
"version": "1.0.0",
"scripts": {
"build": "bun run build:client && bun run build:css",
"build:client": "bun build ./src/client/main.ts --outdir ./dist/assets --minify --external preact --external preact/hooks --external preact/jsx-dev-runtime --external preact/jsx-runtime",
"build:client": "bun build ./src/client/main.ts ./src/client/captcha.ts --outdir ./dist/assets --minify --external preact --external preact/hooks --external preact/jsx-dev-runtime --external preact/jsx-runtime",
"build:css": "bun build ./src/client/style.css --outdir ./dist/assets --entry-naming main.css --minify",
"start": "bun run src/server/main.ts",
"dev": "bun run dev:client & bun run dev:server & bun run dev:css",
"dev:client": "bun --watch build ./src/client/main.ts --outdir ./dist/assets --external preact --external preact/hooks --external preact/jsx-dev-runtime --external preact/jsx-runtime",
"dev:client": "bun --watch build ./src/client/main.ts ./src/client/captcha.ts --outdir ./dist/assets --external preact --external preact/hooks --external preact/jsx-dev-runtime --external preact/jsx-runtime",
"dev:server": "bun --watch run src/server/main.ts",
"dev:css": "bun build ./src/client/style.css --outdir ./dist/assets --entry-naming main.css --watch",
"test": "NODE_ENV=test bun run src/server/test-utils/run-tests.ts",
Expand Down
90 changes: 90 additions & 0 deletions src/client/captcha.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
clearUsedChallenges,
issueChallenge,
verifyCaptcha,
} from "../server/services/captcha";
import { init, sha256hex, solve } from "./captcha";

const mountChallenge = (challenge: ReturnType<typeof issueChallenge>): void => {
const form = document.createElement("form");

const mount = document.createElement("div");
mount.setAttribute("data-captcha", "");
mount.dataset.salt = challenge.salt;
mount.dataset.challenge = challenge.challenge;
mount.dataset.expires = String(challenge.expires);
mount.dataset.maxnumber = String(challenge.maxnumber);
mount.dataset.signature = challenge.signature;

const status = document.createElement("span");
status.className = "captcha-status";
mount.appendChild(status);

const input = document.createElement("input");
input.type = "hidden";
input.name = "captcha_solution";

form.appendChild(mount);
form.appendChild(input);
document.body.appendChild(form);
};

describe("captcha client solver", () => {
const original = {
enabled: process.env.CAPTCHA_ENABLED,
difficulty: process.env.CAPTCHA_DIFFICULTY,
};

beforeEach(() => {
process.env.CAPTCHA_ENABLED = "true";
process.env.CAPTCHA_DIFFICULTY = "2000";
clearUsedChallenges();
});

afterEach(() => {
document.body.innerHTML = "";
if (original.enabled === undefined) delete process.env.CAPTCHA_ENABLED;
else process.env.CAPTCHA_ENABLED = original.enabled;
if (original.difficulty === undefined)
delete process.env.CAPTCHA_DIFFICULTY;
else process.env.CAPTCHA_DIFFICULTY = original.difficulty;
clearUsedChallenges();
});

test("sha256hex matches known NIST vectors (parity with node:crypto)", () => {
expect(sha256hex("abc")).toBe(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
);
expect(sha256hex("")).toBe(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
});

test("solve finds the answer for a real challenge", async () => {
const challenge = issueChallenge();
const answer = await solve(challenge);
expect(answer).not.toBeNull();
expect(sha256hex(`${challenge.salt}${answer}`)).toBe(challenge.challenge);
});

test("init fills the hidden field with a server-verifiable payload", async () => {
const challenge = issueChallenge();
mountChallenge(challenge);

await init();

const input = document.querySelector<HTMLInputElement>(
'input[name="captcha_solution"]',
);
expect(input?.value).toBeTruthy();
// The end-to-end proof: the client-produced payload verifies server-side,
// which can only pass if the hand-written SHA-256 matches node:crypto.
expect(verifyCaptcha(input?.value ?? null)).toBe(true);
});

test("init is a no-op when there is no mount", async () => {
await init();
expect(document.body.innerHTML).toBe("");
});
});
211 changes: 211 additions & 0 deletions src/client/captcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// First-party proof-of-work captcha solver. Standalone, zero-dependency bundle
// (no Preact / no importmap) loaded only on the login page when CAPTCHA_ENABLED is
// on. It reads the challenge the server embedded in the mount element, brute-forces
// the answer, and writes the solved payload into a hidden form field.
//
// The field/attribute names below MUST match src/server/services/captcha.ts. The
// client cannot import that module (it pulls in node:crypto), so the literals are
// duplicated here; a test cross-checks a client-produced payload against the server
// verifier to catch drift.

const SOLUTION_FIELD = "captcha_solution";
// Yield to the event loop every N hashes so a large search never freezes the tab.
const YIELD_EVERY = 5000;

interface Challenge {
salt: string;
challenge: string;
expires: number;
maxnumber: number;
signature: string;
}

// --- Minimal synchronous SHA-256 -------------------------------------------------
// Self-authored (no npm dep). Returns lowercase hex, matching Node's
// createHash("sha256") for UTF-8 input, which the server relies on to verify.

const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);

const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
const toHex8 = (x: number): string => (x >>> 0).toString(16).padStart(8, "0");

export const sha256hex = (input: string): string => {
const bytes = new TextEncoder().encode(input);
const bitLen = bytes.length * 8;
const withOne = bytes.length + 1;
const pad = (56 - (withOne % 64) + 64) % 64;
const total = withOne + pad + 8;

const msg = new Uint8Array(total);
msg.set(bytes);
msg[bytes.length] = 0x80;
const dv = new DataView(msg.buffer);
dv.setUint32(total - 8, Math.floor(bitLen / 0x100000000));
dv.setUint32(total - 4, bitLen >>> 0);

let h0 = 0x6a09e667;
let h1 = 0xbb67ae85;
let h2 = 0x3c6ef372;
let h3 = 0xa54ff53a;
let h4 = 0x510e527f;
let h5 = 0x9b05688c;
let h6 = 0x1f83d9ab;
let h7 = 0x5be0cd19;

const w = new Uint32Array(64);
for (let off = 0; off < total; off += 64) {
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
for (let i = 16; i < 64; i++) {
const a15 = w[i - 15];
const a2 = w[i - 2];
const s0 = rotr(a15, 7) ^ rotr(a15, 18) ^ (a15 >>> 3);
const s1 = rotr(a2, 17) ^ rotr(a2, 19) ^ (a2 >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
}

let a = h0;
let b = h1;
let c = h2;
let d = h3;
let e = h4;
let f = h5;
let g = h6;
let h = h7;

for (let i = 0; i < 64; i++) {
const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ (~e & g);
const t1 = (h + s1 + ch + K[i] + w[i]) | 0;
const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (s0 + maj) | 0;
h = g;
g = f;
f = e;
e = (d + t1) | 0;
d = c;
c = b;
b = a;
a = (t1 + t2) | 0;
}

h0 = (h0 + a) | 0;
h1 = (h1 + b) | 0;
h2 = (h2 + c) | 0;
h3 = (h3 + d) | 0;
h4 = (h4 + e) | 0;
h5 = (h5 + f) | 0;
h6 = (h6 + g) | 0;
h7 = (h7 + h) | 0;
}

return (
toHex8(h0) +
toHex8(h1) +
toHex8(h2) +
toHex8(h3) +
toHex8(h4) +
toHex8(h5) +
toHex8(h6) +
toHex8(h7)
);
};

// --- Solver ----------------------------------------------------------------------

const readChallenge = (mount: HTMLElement): Challenge | null => {
const { salt, challenge, expires, maxnumber, signature } = mount.dataset;
if (!salt || !challenge || !expires || !maxnumber || !signature) return null;
return {
salt,
challenge,
expires: Number(expires),
maxnumber: Number(maxnumber),
signature,
};
};

export const solve = async (c: Challenge): Promise<number | null> => {
for (let n = 0; n <= c.maxnumber; n++) {
if (sha256hex(`${c.salt}${n}`) === c.challenge) return n;
if (n % YIELD_EVERY === 0) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
return null;
};

const encodePayload = (c: Challenge, n: number): string =>
btoa(
JSON.stringify({
salt: c.salt,
challenge: c.challenge,
expires: c.expires,
signature: c.signature,
number: n,
}),
);

export const init = async (): Promise<void> => {
const mount = document.querySelector<HTMLElement>("[data-captcha]");
if (!mount) return;

const input = document.querySelector<HTMLInputElement>(
`input[name="${SOLUTION_FIELD}"]`,
);
if (!input) return;

const challenge = readChallenge(mount);
if (!challenge) return;

const status = mount.querySelector<HTMLElement>(".captcha-status");
const form = mount.closest("form");

// If the user submits before the proof is ready, hold the submit and replay it
// once solved — so a fast typist never trips the server-side check.
let solved = false;
let submitPending = false;
form?.addEventListener("submit", (event) => {
if (!solved) {
event.preventDefault();
submitPending = true;
}
});

const answer = await solve(challenge);
if (answer === null) {
// Couldn't solve (e.g. tampered challenge) — leave the field empty; the server
// rejects and re-renders a fresh challenge.
if (status) status.textContent = "Verification unavailable — please retry.";
return;
}

input.value = encodePayload(challenge, answer);
solved = true;
if (status) status.textContent = "Verified.";
if (submitPending) form?.requestSubmit();
};

// Auto-run when loaded as the standalone bundle. Harmless in tests (no mount → no-op)
// so specs can set up a fixture and call init() themselves.
if (typeof document !== "undefined") {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
void init();
});
} else {
void init();
}
}
35 changes: 35 additions & 0 deletions src/server/components/captcha-widget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { CaptchaWidget } from "./captcha-widget";

const challenge = {
salt: "test-salt",
challenge: "deadbeef",
expires: 1_700_000_000_000,
maxnumber: 100_000,
signature: "abc123",
};

describe("CaptchaWidget", () => {
test("renders nothing when no challenge is passed", () => {
expect(renderToString(<CaptchaWidget challenge={null} />)).toBe("");
expect(renderToString(<CaptchaWidget />)).toBe("");
});

test("renders the mount, hidden field, and solver script when enabled", () => {
const html = renderToString(<CaptchaWidget challenge={challenge} />);

expect(html).toContain("data-captcha");
expect(html).toContain('data-salt="test-salt"');
expect(html).toContain('data-challenge="deadbeef"');
expect(html).toContain('data-signature="abc123"');
expect(html).toContain('name="captcha_solution"');
expect(html).toContain("/assets/captcha.js");
});

test("never leaks the answer to the client", () => {
const html = renderToString(<CaptchaWidget challenge={challenge} />);
// Only the target hash and search bounds are exposed — nothing named "answer".
expect(html).not.toContain("answer");
});
});
Loading