Skip to content

v1.21.0 — adaptive challenge cost

Latest

Choose a tag to compare

@cport1 cport1 released this 28 Jul 22:40

Proof-of-work cost now scales with how suspicious a source has recently been, and the escalation is on wall-clock rather than hashing.

A visitor who has done nothing wrong pays exactly what they paid before.

Why not difficulty

Browser JavaScript runs 1–3M hash/s; native code runs 100–500M/s. Difficulty 6 therefore costs a native solver about a millisecond and a budget Android phone about sixteen seconds. Raising it taxes the slowest legitimate devices and constrains nobody.

Wall-clock has no such asymmetry — nobody can make less time pass. Each challenge now carries a minAgeMs: how long it must be held before its solution is accepted without penalty.

source difficulty minAgeMs
clean, or unknown 4 1500
1–2 recent strong bot verdicts 4 4000
3–5 5 8000
6+ 5 15000

At the 1.5s baseline one address can mint roughly 40 tokens a minute; at 15s, four.

Two changes reduce cost for legitimate traffic:

  • Difficulty now caps at 5, down from 6.
  • A datacenter address no longer raises difficulty at all. It jumped straight to 5, which charged a real person on a corporate VPN or iCloud Private Relay several seconds of blocked hashing for having a shared IP. It raises the time floor instead — reduced throughput for a hosted scraper, and nothing a person filling in a form notices.

The cost lands as latency, not as a score

The client is told minAgeMs and waits it out before submitting, so an elevated cost is a short delay rather than a worse verdict. This matters most for anyone sharing an egress address with whatever earned the delay.

A client that submits early is still scored, but the two cases are separated:

  • Below the universal 1500ms baseline: unchanged, scored as before. Nothing legitimate happens that fast.
  • Between the baseline and the source's own floor: contributory only. A client from an older release does not know to wait and should not be read as automation for it.

Both difficulty and minAgeMs are covered by the challenge signature, so neither can be talked down.

What is remembered

Per (site key, address), for 15 minutes, recording only verdicts at or above 0.8, storing nothing but timestamps. Same shape and privacy class as the rate limiter beside it. It is not cross-session correlation and does not persist behavioral data.

Benchmark

Human false-positive rate 0.00% (0/126), agent true-positive rate 97.33% — both unchanged.

The harness itself needed two fixes first, both invisible until a detection was keyed by source:

  • Sample addresses were derived by hashing the sample id into a 508-address space, so 180 samples collided constantly — 25 shared addresses, 24 of them putting an agent and a human on the same IP. The corpus was manufacturing shared egress its labels never claimed. Addresses are now assigned by position.
  • Runs inherited server-side state from one another: one signal read 2/75 agents against a fresh server and 74/75 against one already benchmarked. Each run now namespaces its own site key.

Continuous integration

Go and Python unit tests now run on every pull request. Previously only Node's did, so a green check said nothing about two of the three implementations — including the test that keeps non-MIT-compatible fingerprinting code out of the project.

Python's tests were also under-reported by discovery: 12 tests and two import errors, where there are 43. server-python/testkit.py bridges them into unittest while keeping their readable per-file output.

Upgrading

No API or configuration changes, and minAgeMs is additive — an older client interoperates, it simply does not wait.

One exception, for the npm library only: @webdecoy/fcaptcha token signatures were truncated to 64 bits on both generation and verification, while the Go, Python and standalone Node servers used the full digest. They are now full-length, so tokens issued by an older library build will not verify after upgrading. Tokens are short-lived; a rolling deploy will settle within their lifetime.