# Bollwark Privacy-first, self-hostable proof-of-work CAPTCHA service in Rust — a reCAPTCHA alternative that sets no cookies and needs no consent banner. Clients solve a memory-hard Argon2id (or SHA-256) puzzle to prove they aren't bots; the server brackets each solve with two scoring passes that adapt difficulty — or short-circuit — based on per-request risk signals. Free to self-host (MIT), or hosted at [bollwark.eu](https://bollwark.eu). - **Two-pass risk scoring.** A puzzle-time pass picks an escalation tier (invisible / checkbox / hard PoW / 429 block) and issues a proof-of-work puzzle at a tier-adjusted difficulty. A verify-time pass re-scores after submit using time-on-page, honeypot, and behavioural telemetry, returning `pass` / `shadow_fail` / `block`. - **Pluggable signals.** Rate, header anomaly, honeypot, time-on-page, and behavioural telemetry are always on; IP reputation (CIDR list) and TLS fingerprint (read from a trusted reverse-proxy header) self-gate on their own config. The service is cookie-free — there's no global scoring toggle and no consent-triggering client storage; every signal runs under legitimate interest with data minimization. - **Drop-in browser widget.** `static/captcha-widget.js` mounts on a `
`, runs the PoW in a Web Worker, and posts a token into your form. SHA-256 uses `crypto.subtle`; Argon2id uses a vendored hash-wasm build. - **Validation dashboard.** Optional SQLite-backed decision log with a vanilla-JS admin UI for inspecting puzzle/verify sessions. ## Quickstart ```bash cargo run # listens on 0.0.0.0:3000 ``` Set an admin token and register a site, then issue and verify a puzzle: ```bash export ADMIN_TOKEN=$(openssl rand -hex 32) SITE_DB_PATH=tmp/sites.db cargo run # 1. Register a site → returns { site_key, secret_key } curl -s -X POST http://localhost:3000/v1/sites \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"my-site"}' | jq # 2. Fetch a puzzle (anonymous; widget does this for you) curl -s "http://localhost:3000/v1/puzzle?site_key=" | jq # 3. Server-to-server verify (Bearer = secret_key) curl -s -X POST http://localhost:3000/v1/verify \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"challenge_id":"…","nonce":"…"}' ``` Without `ADMIN_TOKEN`, `POST /v1/sites` returns 404 — no anonymous provisioning. Without `SITE_DB_PATH`, sites live only in memory and are lost on restart. Open `http://localhost:3000/static/testsite.html` for a working harness that exercises the full flow against a freshly registered site. For a full application walkthrough, see **[INTEGRATION.md](./INTEGRATION.md)**. ## Embedding the widget ```html
``` One tag. `/v1/widget.js` is the entry point — it pulls the stylesheet, the PoW worker and the Argon2 bundle from a content-hashed directory pinned to that build, so the pieces can never go out of sync in a visitor's cache. Self-hosting? Swap in your own host; the path is the same. When the script is served from a different origin than your app, the widget uses the script origin as the CAPTCHA API origin and loads the PoW worker through a same-page Blob wrapper. If you bundle the script into your own build instead, set `data-server-url="https://api.bollwark.eu"` on the widget element. The older two-tag embed (`/static/captcha-widget.css` + `/static/captcha-widget.js`) still works, unversioned — see [INTEGRATION.md](./INTEGRATION.md#3-embed-the-widget). Cross-origin embeds need no server setting: the puzzle endpoint and widget assets answer every origin with wildcard CORS, and the service is cookie-free, so there is no `SameSite` or credentials handling either. To pin a site key to your app's origin, register the site with `allowed_origins`. The widget evaluates its risk tier once on mount (matching Turnstile / hCaptcha behaviour), solves the PoW off-thread, and writes the resulting token into a hidden `` on submit. Your backend then calls `POST /v1/verify` with the site secret to confirm. > **Token contract:** The widget writes a single **opaque token** (like Turnstile / hCaptcha) into the hidden input. Your form handler forwards it verbatim as `{"token": ""}` in the `/v1/verify` body — no parsing required. The server unpacks the challenge id, PoW nonce, honeypot, and behaviour from inside the token. Dwell time is **not** carried in the token; it's derived server-side from the challenge's issuance timestamp, so a client can't claim a longer dwell than actually elapsed. Server-to-server callers that build the request themselves may instead send the explicit fields (`challenge_id` + `nonce`, optional `honeypot`/`behavior`). ## API | Endpoint | Auth | Purpose | |---|---|---| | `POST /v1/sites` | Bearer (`ADMIN_TOKEN`) | Register a site. Returns `{ site_key, secret_key }`. Returns 404 when `ADMIN_TOKEN` is unset. | | `GET /v1/puzzle?site_key=` | None | Score the request and issue a PoW puzzle (`algorithm`/`prefix`/`difficulty`) for any non-block tier, or `429` for `block`. Cookie-free — never sets a cookie. | | `POST /v1/verify` | Bearer (site secret) | Verify a solution. Body: either the opaque `token` from the widget, or explicit `challenge_id` plus `nonce`, with optional `honeypot`, `behavior`. Dwell time is derived server-side, not accepted from the client. | | `GET /healthz` | None | Liveness probe. Always returns `200 ok`. | | `GET /v1/admin/sessions[/:id]` | Bearer (`ADMIN_TOKEN`) | Decision log read API. Mounted only when `ADMIN_DB_PATH` is set. | | `GET /v1/admin/stats` | Bearer (`ADMIN_TOKEN`) | Aggregate decision-log stats (counts, tier/decision breakdowns) for the dashboard. | | `GET /v1/admin/sites` | Bearer (`ADMIN_TOKEN`) | List registered sites with activity aggregates from the decision log. | | `PUT /v1/admin/sites/:id/name` | Bearer (`ADMIN_TOKEN`) | Rename a site. A label only — no credential or scoring change. | | `POST /v1/admin/sites/:id/rotate` | Bearer (`ADMIN_TOKEN`) | Generate a new `secret_key` for a site; the old one is invalidated immediately. | | `DELETE /v1/admin/sites/:id` | Bearer (`ADMIN_TOKEN`) | Delete a site. Future `/v1/verify` calls with its secret will fail. | Static assets (`captcha-widget.js`, `captcha-widget.css`, `captcha-worker.js`, `admin.html`, `testsite.html`) are served from `/static/`. ## Configuration Everything is env-var driven and every setting has a default — `cargo run` works out of the box with the always-on signals (rate, header anomaly, honeypot, time-on-page, behaviour) plus PoW; the service is cookie-free and IP reputation / TLS fingerprint self-gate on their own config. See **[CONFIGURATION.md](./CONFIGURATION.md)** for the full reference, score weights, and file formats. The most load-bearing knobs: - `LISTEN_ADDR` (default `0.0.0.0:3000`), `RUST_LOG` (default `info`) - `PUZZLE_ALGORITHM` — `argon2id` (default, memory-hard) or `sha256`. For Argon2id, tune `ARGON2_M_COST` / `ARGON2_T_COST` / `ARGON2_P_COST`. - `DEFAULT_DIFFICULTY` / `MAX_DIFFICULTY` — base PoW difficulty and upper clamp, in leading zero bits. Defaults track the algorithm: `5` / `10` for argon2id, `18` / `28` for sha256. - `TIER_CHECKBOX_MIN` / `TIER_HARD_POW_MIN` / `TIER_BLOCK_MIN` — puzzle-time score → tier thresholds. - `VERIFY_SHADOW_MIN` / `VERIFY_BLOCK_MIN` — verify-time score thresholds. - Optional signal inputs (off until configured): `IP_REPUTATION_FILE`, `TLS_FINGERPRINT_HEADER` (+ `TLS_FINGERPRINT_FILE`, `TRUSTED_PROXIES`). - Validation dashboard: `ADMIN_DB_PATH` + `ADMIN_TOKEN`. ## Docker Build and run locally: ```bash docker build -t bollwark . docker run --rm -p 3000:3000 \ -e ADMIN_TOKEN= \ -e SITE_DB_PATH=/data/sites.db \ -v bollwark-data:/data \ bollwark ``` The image exposes port `3000`, serves static widget assets from `/static`, and keeps runtime configuration env-var driven. CI publishes `dcr.oxidt.com/bollwark:latest` and `dcr.oxidt.com/bollwark:` from `main`. Public multi-arch images are published to `ghcr.io/hauju/bollwark`. For self-hosting — compose quickstart, reverse-proxy and persistence notes, Coolify setup — see **[DEPLOYMENT.md](./DEPLOYMENT.md)**. ## Architecture ``` src/ api/ Axum router, handlers orchestrating both scoring passes puzzle/ PoW engine — SHA-256 and Argon2id, shared leading-zero check risk/ Signals (rate, headers, reputation, TLS fingerprint), puzzle-time RiskScorer, verify-time VerifyScorer, behaviour storage/ Store trait + in-memory implementation (Redis/Mongo planned) dashboard/ SQLite decision log + /v1/admin/* read API site/ Site registration types config.rs AppConfig from env error.rs CaptchaError → IntoResponse static/ Embeddable widget, Web Worker, admin UI, test harness ``` Two passes bracket every successful solve: 1. **Puzzle-time** (`GET /v1/puzzle`): score the request, pick an `EscalationTier`, issue a PoW puzzle at a tier-adjusted difficulty, or return `429` for `Block`. 2. **Verify-time** (`POST /v1/verify`): after the PoW check passes, re-score using submit-only signals and return `Pass` / `ShadowFail` (success + WARN log) / `Block`. Challenges are single-use and deleted after a successful PoW verification — even if the verify-time decision is `Block`. ## Development ```bash cargo build cargo test # unit + integration cargo clippy -- -D warnings cargo fmt ``` A `justfile` wraps these (`just build`, `just test`, `just lint`, `just ci`). ### Offline improvement Opt-in, retention-bounded training samples can be evaluated locally with `cargo run --example tune_weights -- config.json NEW_OUTPUT_DIR`. The runner produces an aggregate report and, only after held-out gates pass, candidate weights for review. No automatic production changes or external model calls. See [TUNING.md](TUNING.md). ### Validation harnesses - **Structured logs** — `LOG_FORMAT=json cargo run` emits JSONL on stderr. `event=puzzle_decision` and `event=verify_decision` carry the score, tier/decision, signal breakdown, and outcome. Pipe through `jq -c 'select(.event == "puzzle_decision")'` for clean parsing. - **Load generator** — `cargo run --release --example loadgen -- --base http://127.0.0.1:3000 --requests 200 --concurrency 16` drives four scenarios (`happy`, `no_ua`, `burst`, `full_solve`) and prints latency percentiles + tier distribution. For `full_solve`, run the server with `DEFAULT_DIFFICULTY=12 MAX_DIFFICULTY=16`. - **Playwright e2e** — `cd e2e && bun install && bunx playwright install chromium && bun run test`. Auto-spawns `cargo run`; set `CAPTCHA_REUSE_SERVER=1` to reuse a server you started yourself (so you can capture its JSONL). Requires Rust 1.85+ (edition 2024). ## License MIT — see [LICENSE](LICENSE). The vendored `static/vendor/argon2.umd.min.js` (hash-wasm) is also MIT; see `static/vendor/README.md`.