sdk.js fetches a challenge from GET /v1/c, solves a small SHA-256
proof of work with WebCrypto and attaches the result to every event of that page load. The exact
bytes are in wire-v1.md. This page says what the proof is worth and what it costs.
What it shows and what it does not
- A request without a valid proof did not go through the challenge flow. Naive server-side posts (a plain HTTP client posting a JSON body) are rejected.
- It does not prove that a browser ran the page. A scripted client can fetch a challenge, solve
it in a few milliseconds and forge
OriginandSec-Fetch-*headers. The proof raises the cost of forged traffic and catches naive posts. It cannot stop a determined actor. - Replay protection is an in-memory limit on each edge node. A captured proof can be replayed up to the per-proof limit on each of the three nodes, at most three times the limit in total.
- The challenge is held in page memory only: no cookie, no
localStorage, nosessionStorage. A new page load gets a new challenge. - Difficulty (
bits) and time (iat) come from the server, so difficulty can change without a tracker release, and a wrong client clock does not matter. The tracker refuses challenges above 24 bits, so a misconfiguration cannot freeze visitors’ browsers.
docs/security/bot-and-inflation.md (phase 18) covers the whole bot and inflation
strategy.
Chosen difficulty
PROOF_BITS = 12 is the gateway’s starting value. It is the highest tested value whose median
solve time stays under 150 ms at a 6x CPU slowdown in all three engines. 14 bits exceeds 150 ms in
every engine at 6x.
Measurements (2026-11-12)
packages/tracker/bench/proof-cost.mjs, raw numbers in
packages/tracker/bench/results/2026-11-12.json. Intel Core Ultra 5 225, headless Playwright
1.64.1: Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2. 101 solves per cell, each against a fresh
random salt, after a warm-up. Batch size 64 (see below). Times in milliseconds.
Playwright can throttle the CPU only in Chromium (CDP Emulation.setCPUThrottlingRate). To slow all
three engines the same way, each engine ran inside a transient systemd scope with a CPU quota
(CPUQuota=25% for 4x, 17% for 6x, quota period 10 ms). Chromium was measured both ways; the two
methods agree within noise. The quota caps all browser processes together, so for Firefox, which
spreads the work over more processes, it is harsher than 6x (its hash rate fell 12x).
Solve time grows with 2^bits and is geometrically distributed, so p90 is about 3x the median.
Firefox and WebKit report
performance.now() at 1 ms resolution.
Batch size. Each WebCrypto digest is an asynchronous call, so the solver issues digests in
batches and awaits them together. Hash rate in hashes per ms at 12 bits:
One digest at a time is up to 8x slower in WebKit and 3x in Firefox. 64 is at or within noise of
the best everywhere, so
SOLVE_BATCH = 64.
Request behaviour in each engine
packages/tracker/bench/cors-probe.mjs, raw output in bench/results/cors-2026-11-12.json. Page
on http://127.0.0.1, gateway on http://localhost (two different sites), the tracker’s exact
fetch options:
All three send
Origin on both requests. The gateway’s fetch-metadata check (phase 4) can expect
cross-site (or same-site when the tenant site shares the base domain), cors and empty.
Filter lists
tests/adblock/ downloads EasyList, EasyPrivacy, uBlock filters, uBlock privacy, uBlock badware,
AdGuard Base, AdGuard Tracking Protection and Peter Lowe’s list on every run and checks the three
public urls with @ghostery/adblocker as script, xmlhttprequest and ping requests from a
third-party page. On 2026-11-12 no list matched any of them as script or xmlhttprequest. The
only ping match is EasyPrivacy’s generic $ping,3p, which blocks every third-party beacon
whatever its url; the tracker never sends ping requests (it uses fetch, never sendBeacon),
and the test checks that too.
Observed request headers
Recorded on 2026-11-12 bytests/e2e/specs/headers.spec.ts with Playwright 1.64.1, from a tenant page on https://static.sa-tenant.example to the gateway on https://in.sa-test.example (two different registrable domains, as in production). The recorder is the test-only gateway fixture in tests/e2e/fixture/. No engine sent an OPTIONS request in any test of the matrix, and none sent a Cookie header.
All three engines also sent
Accept: */* and Referer set to the page URL (without fragment) on all three requests. With Global Privacy Control on (Firefox preference privacy.globalprivacycontrol.enabled), the tracker loaded but sent neither /v1/c nor /v1/e.