diff --git a/.env.example b/.env.example index 6204811..aa30426 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 6ce292d..c6e183c 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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). @@ -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). diff --git a/package.json b/package.json index e823068..0690d39 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/captcha.test.ts b/src/client/captcha.test.ts new file mode 100644 index 0000000..dc94e72 --- /dev/null +++ b/src/client/captcha.test.ts @@ -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): 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( + '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(""); + }); +}); diff --git a/src/client/captcha.ts b/src/client/captcha.ts new file mode 100644 index 0000000..9e47d97 --- /dev/null +++ b/src/client/captcha.ts @@ -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 => { + 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 => { + const mount = document.querySelector("[data-captcha]"); + if (!mount) return; + + const input = document.querySelector( + `input[name="${SOLUTION_FIELD}"]`, + ); + if (!input) return; + + const challenge = readChallenge(mount); + if (!challenge) return; + + const status = mount.querySelector(".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(); + } +} diff --git a/src/server/components/captcha-widget.test.tsx b/src/server/components/captcha-widget.test.tsx new file mode 100644 index 0000000..b265113 --- /dev/null +++ b/src/server/components/captcha-widget.test.tsx @@ -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()).toBe(""); + expect(renderToString()).toBe(""); + }); + + test("renders the mount, hidden field, and solver script when enabled", () => { + const html = renderToString(); + + 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(); + // Only the target hash and search bounds are exposed — nothing named "answer". + expect(html).not.toContain("answer"); + }); +}); diff --git a/src/server/components/captcha-widget.tsx b/src/server/components/captcha-widget.tsx new file mode 100644 index 0000000..de0c801 --- /dev/null +++ b/src/server/components/captcha-widget.tsx @@ -0,0 +1,49 @@ +import type { JSX } from "react"; +import { getAssetUrl } from "../services/assets"; +import { + CAPTCHA_SOLUTION_FIELD, + type CaptchaChallenge, +} from "../services/captcha"; + +interface CaptchaWidgetProps { + challenge?: CaptchaChallenge | null; +} + +/** + * Renders the proof-of-work captcha into a login form. Emits the challenge as + * data-* attributes for the client solver, a hidden field it fills with the solved + * payload, and the standalone solver bundle. Renders nothing when captcha is + * disabled (no challenge passed), so the login form is unchanged in that case. + * + * The script is emitted here rather than in BaseLayout so only the login response + * loads it, and everything is same-origin — no CSP change is required. + */ +export const CaptchaWidget = ({ + challenge, +}: CaptchaWidgetProps): JSX.Element | null => { + if (!challenge) { + return null; + } + + return ( + <> +
+ {/* No visible label — the proof of work runs silently. Kept as a + screen-reader-only live region so assistive tech still gets feedback. */} + + Verifying you're human… + +
+ +