diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-14 13:34:47 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-14 13:34:47 +0200 |
| commit | 51705056a578d2649a989bf14a056fbcfae58a6b (patch) | |
| tree | dfd078aba518427387fca29b9d43c3723b030008 /packages/meshbay-hub/src/meshbay_hub/captcha.py | |
| parent | daec4bc6a2a9f9e038e5269586b06018ff327a27 (diff) | |
| download | meshbay-51705056a578d2649a989bf14a056fbcfae58a6b.tar.gz | |
feat(hub): replace reCAPTCHA with a self-hosted proof of work
Sign-up and passphrase reset sent every visitor's address and browser
details to Google, which needed a consent the sign-up form could not ask
for freely. The hub now issues its own challenge (ALTCHA's scheme, signed
with a key derived from the hub's private key, single-use, 30 minutes)
and the page solves it with SubtleCrypto while the form is filled in.
- GET /v1/users/captcha issues a challenge; 404 captcha_disabled when off.
- [captcha] enabled / max_number replace site_key / secret_key, which are
now ignored with a warning; MESHBAY_CAPTCHA_ENABLED replaces the old
variables.
- No Google origin left in the CSP of the hub, the desktop shell or the
Android shell; captcha_site_key leaves /v1/hub/info.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/captcha.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/captcha.py | 145 |
1 files changed, 73 insertions, 72 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/captcha.py b/packages/meshbay-hub/src/meshbay_hub/captcha.py index cf33d52..e6faf1a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/captcha.py +++ b/packages/meshbay-hub/src/meshbay_hub/captcha.py @@ -1,85 +1,86 @@ -"""reCAPTCHA v2 server-side verification.""" +"""Proof-of-work challenge gating registration and password reset. -import logging +The hub hands out a challenge; the page finds the number behind it by trying +them in turn and sends the answer with the form. Nobody else is asked: no +script, cookie or address leaves for a third party, which is what the +reCAPTCHA this replaced could not say (docs/MESHBAY_DESIGN.md §7.7). -import httpx +What it buys is cost, not a verdict on who is human. A person never sees it — +the page solves while the form is being filled in — and a script pays it once +per attempt. The bounds that hold against a script with machines to spare are +the per-IP limits on the two routes and, above all, the mail budget +(`mail.py`): the sign-up and the reset are what make this hub send mail. -log = logging.getLogger(__name__) +The scheme is ALTCHA's, keyed to this hub. A challenge is the SHA-256 of +`<nonce>.<expires>.<number>`, with `number` drawn below `max_number`; its HMAC +is the hub's word that it issued it. The answer carries the nonce, the expiry, +the number and that HMAC, so the hub recomputes the challenge rather than +remembering it — and every worker, and the next restart, agrees on it, since +the key is derived from the hub's own private key and not drawn per process. +What is remembered is an answer once spent, so one solve is one form. +""" -VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" +import hashlib +import hmac +import secrets +import time +from meshbay_hub.auth import hub_private_key_pem -async def verify_captcha( - secret_key: str, - token: str, - remote_ip: str | None = None, - allowed_hosts: frozenset[str] | set[str] | None = None, - allow_unattributed: bool = False, -) -> bool: - """True when Google accepts the token, and it came from a host we expect. +# How long a challenge stays good. Long enough for somebody filling in the form +# slowly; the page fetches a fresh one if it outlived this before submitting. +CHALLENGE_TTL = 1800 - `allowed_hosts` is the answer to a reCAPTCHA key whose own origin check is - turned off. That check compares the *page's hostname* against the key's - domain list, and the desktop client's page is served from `app://meshbay` - — Chromium refuses a service worker on a custom scheme and the interface - ships inside the package, so it cannot be `https://<hub>` and the hostname - cannot be the hub's. Google answers "Invalid domain for site key" inside - its own widget, which is not something the client can configure away. +_KEY_LABEL = b"meshbay captcha v1" +# Answers already accepted, until their challenge would have expired anyway. +_spent: dict[str, int] = {} - Turning the key's origin check off moves that decision here, where the hub - knows which hosts are its own. `siteverify` reports the hostname it - observed, not one the caller asserts, so this is a real check and not a - formality — a bot embedding the site key on a page of its own is refused - on the hostname it actually served from. - `None` (the default) skips it, which is what a deployment leaving the - origin check with Google wants: it is then already done, one layer up. +def _key() -> bytes: + return hmac.new(hub_private_key_pem(), _KEY_LABEL, hashlib.sha256).digest() - `allow_unattributed` covers the case that made the desktop client fail - anyway. A solve from a page Google cannot attribute to a domain comes back - with an **empty** hostname, not the host part of the origin — measured - live: `app://meshbay` reports `''`, never `'meshbay'`. So an allowlist - entry can never match it, and an empty string cannot be an allowlist entry - either: a blank in a TOML list is a typo far more often than it is an - intention, and the config parser drops blanks for that reason. - What it admits is every non-web client, not only ours — a `file://` page or - somebody else's Electron application report the same nothing. That is the - same bar the desktop client's own origin would have been (`app://meshbay` - is not a credential; any application can claim it), and it is a bar: the - captcha still has to be *solved*, per token. What is given up is the origin - restriction for non-web clients, not the captcha. +def _sign(challenge: str) -> str: + return hmac.new(_key(), challenge.encode(), hashlib.sha256).hexdigest() + + +def issue(max_number: int) -> dict: + """A fresh challenge, in the shape the page solves (`static/captcha.js`).""" + prefix = f"{secrets.token_hex(12)}.{int(time.time()) + CHALLENGE_TTL}." + number = secrets.randbelow(max_number) + challenge = hashlib.sha256(f"{prefix}{number}".encode()).hexdigest() + return { + "prefix": prefix, + "challenge": challenge, + "max_number": max_number, + "signature": _sign(challenge), + "expires_in": CHALLENGE_TTL, + } + + +def verify(token: str) -> bool: + """True for an answer this hub issued, still in date and not spent before. + + The token is `<nonce>.<expires>.<number>.<signature>`. Nothing in it is + trusted on its own: the challenge is recomputed from the first three, and + only this hub's key turns that into the signature. """ - payload: dict[str, str] = {"secret": secret_key, "response": token} - if remote_ip: - payload["remoteip"] = remote_ip - try: - async with httpx.AsyncClient(timeout=5) as client: - resp = await client.post(VERIFY_URL, data=payload) - resp.raise_for_status() - result = resp.json() - if not result.get("success"): - log.info("captcha rejected: %s", result.get("error-codes", [])) - return False - if allowed_hosts is not None: - # Logged at warning with the hostname spelled out: this is also - # how an operator finds what to allow after adding a client - # whose origin they have not seen before. It is how the empty - # one was found. - host = result.get("hostname") or "" - if not host: - if not allow_unattributed: - log.warning( - "captcha solved on a host Google did not attribute; " - "set captcha.allow_unattributed_host to admit " - "non-web clients such as the desktop application") - return False - elif host not in allowed_hosts: - log.warning( - "captcha solved on an unexpected host %r; allowed: %s", - host, sorted(allowed_hosts)) - return False - return True - except Exception: - log.exception("captcha verification request failed") + parts = token.split(".") + if len(parts) != 4: + return False + nonce, expires_s, number_s, signature = parts + if not (expires_s.isdigit() and number_s.isdigit()): + return False + now = int(time.time()) + if not now < int(expires_s) <= now + CHALLENGE_TTL: + return False + challenge = hashlib.sha256(f"{nonce}.{expires_s}.{number_s}".encode()).hexdigest() + if not hmac.compare_digest(_sign(challenge), signature): + return False + for spent, until in list(_spent.items()): + if until <= now: + del _spent[spent] + if signature in _spent: return False + _spent[signature] = int(expires_s) + return True |