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 | |
| 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')
29 files changed, 394 insertions, 580 deletions
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt index 699e0c3..6bd2a3f 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -198,9 +198,8 @@ class MainActivity : Activity() { return docs.firstNotNullOfOrNull { it.serve(path) } ?: refused() } if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused() - // reCAPTCHA (sign-up) and nothing else goes to the network from - // the page; the policy says the same, this is the second wall. - if (UiAssets.isRecaptcha(url.host) && url.scheme == "https") return null + // Nothing goes to the network from the page; the policy says + // the same, this is the second wall. if (url.scheme == "blob" || url.scheme == "data") return null return refused() } @@ -211,7 +210,7 @@ class MainActivity : Activity() { // The hub must never become the document origin. A link out // opens in the person's browser, not in a window holding keys. if (request.isForMainFrame && (url.scheme == "https" || url.scheme == "http")) openExternally(url) - return !(UiAssets.isRecaptcha(url.host) && !request.isForMainFrame) + return true } } web.webChromeClient = object : WebChromeClient() { diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt index 2300668..0721db3 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt @@ -45,10 +45,6 @@ class UiAssets(private val context: Context) : WebViewAssetLoader.PathHandler { const val PREFIX = "/ui/" const val START = "$ORIGIN${PREFIX}index.html" - // reCAPTCHA gates sign-up here as it does in a browser and on the - // desktop; these two hosts and no others. - private const val RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com" - /** * meshbay-client/src/main.js's CSP, directive for directive * (test_android_shell.py holds them together). `'wasm-unsafe-eval'` is @@ -56,22 +52,20 @@ class UiAssets(private val context: Context) : WebViewAssetLoader.PathHandler { */ val CSP = listOf( "default-src 'none'", - "script-src 'self' 'wasm-unsafe-eval' $RECAPTCHA_SRC", + "script-src 'self' 'wasm-unsafe-eval'", "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: $RECAPTCHA_SRC", + "img-src 'self' data: blob:", "media-src 'self' blob:", "font-src 'self'", - "connect-src 'self' $RECAPTCHA_SRC", + "connect-src 'self'", "worker-src 'self'", "object-src blob:", - "frame-src blob: $RECAPTCHA_SRC", + "frame-src blob:", "frame-ancestors 'none'", "base-uri 'none'", "form-action 'none'", ).joinToString("; ") - fun isRecaptcha(host: String?) = host == "www.google.com" || host == "www.gstatic.com" - fun contentType(name: String): String = when (File(name).extension.lowercase()) { "html" -> "text/html" "js", "mjs" -> "text/javascript" diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 33388e8..8845b7b 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -115,29 +115,16 @@ function nodeLogHint() { // The hub is reachable under connect-src, for its API and its signaling socket. // It is deliberately absent from script-src: nothing it returns is executed, // which is the whole reason this application exists (T3). -// -// The one exception is reCAPTCHA, used to gate sign-up (and password reset) the -// same way it gates them in the browser. Its script comes from www.google.com, -// its challenge is a www.google.com iframe, and its assets sit on -// www.gstatic.com. These two hosts — and only these two — are allowed under -// `script-src`, `frame-src` and `img-src` for that purpose. It is a real, if -// small, dent in "no third-party code runs here": Google's reCAPTCHA script -// executes in the renderer. It is accepted deliberately so a native sign-up is -// gated like a web one without asking the user to do anything extra, and it is -// the *same* dependency the hub-served SPA already carries. If sign-up ever -// moves to a proof-of-work challenge, delete RECAPTCHA_SRC and the three -// directives that spread it, and the widget in auth-page.js with them. -const RECAPTCHA_SRC = 'https://www.google.com https://www.gstatic.com'; const CSP = [ "default-src 'none'", - `script-src 'self' 'wasm-unsafe-eval' ${RECAPTCHA_SRC}`, + "script-src 'self' 'wasm-unsafe-eval'", "style-src 'self' 'unsafe-inline'", - `img-src 'self' data: blob: ${RECAPTCHA_SRC}`, + "img-src 'self' data: blob:", "media-src 'self' blob:", "font-src 'self'", // Every hub call leaves from this process, so the page connects to nothing - // but its own files and reCAPTCHA; see the hub's webapp.CSP. - `connect-src 'self' ${RECAPTCHA_SRC}`, + // but its own files; see the hub's webapp.CSP. + "connect-src 'self'", "worker-src 'self'", // `blob:` here and in `frame-src` are one thing, not two: the PDF preview. // @@ -157,7 +144,7 @@ const CSP = [ // is PDFium parsing bytes that came from a node — exactly what a browser // does with the same file. "object-src blob:", - `frame-src blob: ${RECAPTCHA_SRC}`, + "frame-src blob:", "frame-ancestors 'none'", "base-uri 'none'", "form-action 'none'", diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index 0d2d39c..b47f076 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -7,19 +7,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub import __version__, hub_settings from meshbay_hub.api import federation from meshbay_hub.auth import hub_public_key_pem -from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import get_db, get_engine router = APIRouter(prefix="/v1/hub", tags=["hub"]) -_cfg: HubConfig | None = None - - -def set_config(cfg: HubConfig) -> None: - global _cfg - _cfg = cfg - - @router.get("/info") async def hub_info(db: AsyncSession = Depends(get_db)): """Versions and the instance policy a client needs before signing in.""" @@ -40,7 +31,6 @@ async def hub_info(db: AsyncSession = Depends(get_db)): # which answers 503 — this is the same answer somewhere an operator can # read it without opening the source. "federation": federation.FEDERATION_ENABLED, - "captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "", # How long a browser tab stays signed in with nobody at it. The page # measures it (static/idle.js); the hub only says how long. "browser_idle_hours": diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 130240e..fbba6d7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -100,18 +100,24 @@ def _generate_code() -> str: async def _verify_captcha_or_raise(token: str | None, request: Request) -> None: if not token: raise HTTPException(status_code=400, detail="captcha_required") - from meshbay_hub.captcha import verify_captcha - ok = await verify_captcha( - _cfg.captcha.secret_key, # type: ignore[union-attr] - token, - request.client.host if request.client else None, - _cfg.captcha.host_check, # type: ignore[union-attr] - _cfg.captcha.allow_unattributed_host, # type: ignore[union-attr] - ) - if not ok: + from meshbay_hub.captcha import verify + if not verify(token): raise HTTPException(status_code=400, detail="captcha_failed") +@router.get("/captcha") +@limiter.limit("30/minute") +async def captcha_challenge(request: Request): + """A proof-of-work challenge for the register and reset-request forms. + + 404 when this hub has the check turned off, so the page sends no answer. + """ + if not (_cfg and _cfg.captcha.enabled): + raise HTTPException(status_code=404, detail="captcha_disabled") + from meshbay_hub.captcha import issue + return issue(_cfg.captcha.max_number) + + # ── Models ──────────────────────────────────────────────────────────────────── # Mirrored by USERNAME_MIN_LEN in static/auth-page.js; test_username_floor.py diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index badbdda..adbd522 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -82,21 +82,19 @@ _NO_STORE = {"Cache-Control": "no-store"} # `<script>`. `'wasm-unsafe-eval'` is required for the Argon2id WASM. The hub's # own origin is deliberately absent from `script-src`: a response it returns is # never executed, which is the point of T3. -_RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com" CSP = "; ".join([ "default-src 'none'", - f"script-src 'self' 'wasm-unsafe-eval' {_RECAPTCHA_SRC}", + "script-src 'self' 'wasm-unsafe-eval'", "style-src 'self' 'unsafe-inline'", - f"img-src 'self' data: blob: {_RECAPTCHA_SRC}", + "img-src 'self' data: blob:", "media-src 'self' blob:", "font-src 'self'", # The page talks to its own origin and nothing else — the hub, the node - # arrives over WebRTC, which this directive does not govern. reCAPTCHA's - # two origins are the only others, kept so its script may call home from - # this page; the register and reset views were driven in Chrome and Firefox - # against Google's test keys with this line and reported no violation. - # `https: wss:` let an injected script post anything anywhere. - f"connect-src 'self' {_RECAPTCHA_SRC}", + # arrives over WebRTC, which this directive does not govern. No third + # party has an origin here: the sign-up check is a proof of work the hub + # issues itself (captcha.py). `https: wss:` let an injected script post + # anything anywhere. + "connect-src 'self'", "worker-src 'self'", # `object-src` exists for one thing, and `frame-src`'s `blob:` for the same # thing: previewing a PDF without writing it anywhere. @@ -123,9 +121,10 @@ CSP = "; ".join([ # response it is holding. Without it Chrome refuses the frame, the worker is # never asked, and the page waits out its timeout for a download that cannot # happen — on Firefox and Safari that is the *only* way to write a large - # file to disk, so the whole path was dead. Added when reCAPTCHA needed a - # frame, which is why nobody connected the two. - f"frame-src 'self' blob: {_RECAPTCHA_SRC}", + # file to disk, so the whole path was dead. It was missing because the + # directive had been written for a third-party captcha frame, and nobody + # connected the two. + "frame-src 'self' blob:", # `'self'`, not `'none'`, and the difference is one same-origin iframe. # # The threat frame-ancestors answers is clickjacking: a *foreign* page diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 2ad71bd..6155470 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -23,7 +23,6 @@ from meshbay_hub.api.federation import router as federation_router from meshbay_hub.api.groups import router as groups_router from meshbay_hub.api.health import router as health_router from meshbay_hub.api.hub import router as hub_router -from meshbay_hub.api.hub import set_config as hub_set_config from meshbay_hub.api.invite_links import redeem_router as invite_links_redeem_router from meshbay_hub.api.invite_links import router as invite_links_router from meshbay_hub.api.middleware import limiter @@ -140,7 +139,6 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: generate_hub_keypair(kp) load_hub_keypair(kp, cfg.identity.id) users_set_config(cfg) - hub_set_config(cfg) set_admin_usernames(cfg.identity.admin_usernames) await _backfill_email_hashes() 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 diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py index e618510..f710ead 100644 --- a/packages/meshbay-hub/src/meshbay_hub/config.py +++ b/packages/meshbay-hub/src/meshbay_hub/config.py @@ -66,33 +66,15 @@ class JWTConfig: @dataclass class CaptchaConfig: - site_key: str = "" - secret_key: str = "" - # Hostnames a solved captcha may have come from, checked against the one - # `siteverify` reports. Empty means "do not check", which is right while - # the reCAPTCHA key does its own origin check — it is then already done. - # - # Set this when that check is turned off in the reCAPTCHA console, which is - # what the desktop client needs: its page is served from `app://meshbay`, - # so the hostname Google sees is not the hub's and never can be. See - # docs/MESHBAY_DESIGN.md §7.7. - allowed_hosts: list[str] = field(default_factory=list) - # A solve from a page Google cannot attribute to a domain reports an empty - # hostname — `app://meshbay` does, measured live, and so does any other - # non-web client. No `allowed_hosts` entry can match that, and a blank - # entry is not the answer: the parser drops blanks because in a TOML list - # a blank is a typo far more often than an intention. Hence a named flag, - # which also states the trade at the place it is made. - allow_unattributed_host: bool = False - - @property - def enabled(self) -> bool: - return bool(self.site_key and self.secret_key) - - @property - def host_check(self) -> frozenset[str] | None: - """The set to hand `verify_captcha`, or None for "do not check".""" - return frozenset(self.allowed_hosts) if self.allowed_hosts else None + # The proof of work on registration and password reset (captcha.py). On + # unless a configuration turns it off, which is for development and for a + # hub nobody can reach. + enabled: bool = True + # The work asked of the page: it tries numbers from 0 until one matches, so + # half of this on average and all of it at worst. Sized for the slowest + # phone that signs up — about three seconds there, solved while the form is + # being filled in — not for the desktop, which takes a fraction of that. + max_number: int = 500_000 @dataclass @@ -183,12 +165,12 @@ def load_config(path: Path | None = None) -> HubConfig: ): setattr(cfg.mail, name, ml.get(name, getattr(cfg.mail, name))) if cap := raw.get("captcha", {}): - cfg.captcha.site_key = cap.get("site_key", cfg.captcha.site_key) - cfg.captcha.secret_key = cap.get("secret_key", cfg.captcha.secret_key) - if hosts := cap.get("allowed_hosts"): - cfg.captcha.allowed_hosts = [str(h).strip() for h in hosts if str(h).strip()] - if "allow_unattributed_host" in cap: - cfg.captcha.allow_unattributed_host = bool(cap["allow_unattributed_host"]) + cfg.captcha.enabled = bool(cap.get("enabled", cfg.captcha.enabled)) + cfg.captcha.max_number = max(1, int(cap.get("max_number", cfg.captcha.max_number))) + if "site_key" in cap or "secret_key" in cap: + log.warning("captcha.site_key and captcha.secret_key are no longer " + "read: registration is gated by a proof of work, with " + "no third party; remove them from the configuration") break # Env var overrides @@ -204,13 +186,7 @@ def load_config(path: Path | None = None) -> HubConfig: cfg.identity.private_key_path = Path(kp).expanduser() if admin_users := os.environ.get("MESHBAY_ADMIN_USERS"): cfg.identity.admin_usernames = [u.strip() for u in admin_users.split(",") if u.strip()] - if captcha_site := os.environ.get("MESHBAY_CAPTCHA_SITE_KEY"): - cfg.captcha.site_key = captcha_site - if captcha_secret := os.environ.get("MESHBAY_CAPTCHA_SECRET_KEY"): - cfg.captcha.secret_key = captcha_secret - if captcha_hosts := os.environ.get("MESHBAY_CAPTCHA_ALLOWED_HOSTS"): - cfg.captcha.allowed_hosts = [h.strip() for h in captcha_hosts.split(",") if h.strip()] - if unattributed := os.environ.get("MESHBAY_CAPTCHA_ALLOW_UNATTRIBUTED_HOST"): - cfg.captcha.allow_unattributed_host = unattributed.lower() in ("1", "true", "yes") + if captcha := os.environ.get("MESHBAY_CAPTCHA_ENABLED"): + cfg.captcha.enabled = captcha.lower() in ("1", "true", "yes") return cfg diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index e014f61..c5374f8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -8,6 +8,7 @@ import { import * as platform from './platform.js'; import { Icon } from './icon.js'; import { loadPending } from './invite-link.js'; +import { solve } from './captcha.js'; const PASSWORD_MIN_BITS = 60; const PASSWORD_MIN_LEN = 12; @@ -44,73 +45,63 @@ function PasswordInput({ `; } -// ── reCAPTCHA v2 helper ────────────────────────────────────────────────────── - -let _captchaSiteKey = null; -let _captchaKeyFetched = false; - -async function fetchCaptchaSiteKey() { - if (_captchaKeyFetched) return _captchaSiteKey; - try { - const info = await hubFetch('/v1/hub/info'); - _captchaSiteKey = info.captcha_site_key || null; - } catch { _captchaSiteKey = null; } - _captchaKeyFetched = true; - return _captchaSiteKey; -} - -function loadRecaptchaScript() { - if (document.getElementById('recaptcha-script')) return; - const s = document.createElement('script'); - s.id = 'recaptcha-script'; - s.src = 'https://www.google.com/recaptcha/api.js?render=explicit'; - s.async = true; - s.defer = true; - document.head.appendChild(s); -} +// ── Proof of work (captcha.js) ─────────────────────────────────────────────── +// +// Fetched and solved as soon as the form is on screen, so by the time somebody +// has typed a name, an address and a passphrase twice there is nothing left to +// wait for. Nothing is drawn: there is nothing for a person to do. A hub with +// the check turned off answers 404, and the form then sends no answer at all. function useCaptcha() { - const [siteKey, setSiteKey] = useState(_captchaSiteKey); - const [token, setToken] = useState(null); - const containerRef = useRef(null); - const widgetId = useRef(null); + const pending = useRef(null); + const abort = useRef(null); - useEffect(() => { - fetchCaptchaSiteKey().then(k => { - if (k) { setSiteKey(k); loadRecaptchaScript(); } - }); + const start = useCallback(() => { + if (abort.current) abort.current.abort(); + const ctl = new AbortController(); + abort.current = ctl; + pending.current = (async () => { + let challenge; + try { + challenge = await hubFetch('/v1/users/captcha'); + } catch (err) { + return err.message === 'captcha_disabled' ? { token: null, until: Infinity } : null; + } + // A minute short of the hub's own limit, so an answer is never sent + // that expires on its way there. + const until = Date.now() + (challenge.expires_in - 60) * 1000; + return { token: await solve(challenge, ctl.signal), until }; + })(); }, []); useEffect(() => { - if (!siteKey || !containerRef.current) return; - const poll = setInterval(() => { - if (window.grecaptcha && window.grecaptcha.render && widgetId.current === null) { - clearInterval(poll); - widgetId.current = window.grecaptcha.render(containerRef.current, { - sitekey: siteKey, - callback: (tk) => setToken(tk), - 'expired-callback': () => setToken(null), - theme: document.documentElement.getAttribute('data-theme') === 'dark' - ? 'dark' : 'light', - }); - } - }, 100); - return () => clearInterval(poll); - }, [siteKey]); + start(); + return () => abort.current && abort.current.abort(); + }, [start]); - const reset = useCallback(() => { - if (widgetId.current !== null && window.grecaptcha) { - window.grecaptcha.reset(widgetId.current); - setToken(null); + // The answer for this submission — waited for if it is still being worked + // out, and worked out again if the form sat open past its expiry or the + // first fetch failed. Null only when the hub has no check, or cannot be + // reached, in which case the hub's own refusal is what the person sees. + const take = useCallback(async () => { + let got = await pending.current; + if (!got || got.until < Date.now()) { + start(); + got = await pending.current; } - }, []); + return got ? got.token : null; + }, [start]); - const widget = siteKey - ? html`<div ref=${containerRef} - style="display:flex;justify-content:center;margin:12px 0"></div>` - : null; + // An answer is spent once the hub has checked it, whatever happened after — + // a name already taken is refused past that point. Call after a failure. + return { take, renew: start }; +} - return { token, widget, reset, enabled: !!siteKey }; +// The hub's refusal of the proof of work, which a person can do nothing about +// but try again, in words rather than as the code it is sent as. +function errorText(err) { + return err.message === 'captcha_required' || err.message === 'captcha_failed' + ? t('captcha.failed') : err.message; } function passwordBits(pw) { @@ -411,11 +402,9 @@ export function RegisterPage() { const rk = window.MeshBayKeys.generateRecoveryKey(); // `name` (trimmed), not the raw field: the hub stores the trimmed // username and every key derivation must fold in the same string. - // `captcha.token` rides along — the submit button is already disabled - // until it is set when a captcha is configured (see the form below). const reg = await window.MeshBayKeys.registerUser( name, email, password, emailRecovery ? rk.mnemonic : null, - captcha.token); + await captcha.take()); // An account made in the desktop application starts without browser // access: its identities stay on this device and nothing of them is // left on nodes until the person turns it on, here, natively. @@ -433,17 +422,14 @@ export function RegisterPage() { body: { username: name, email, password, pk_user_ed25519: '', pk_user_x25519: '', - captcha_token: captcha.token, + captcha_token: await captcha.take(), }, }); setPhase('verify'); } } catch (err) { - setError(err.message); - // A reCAPTCHA token is single-use: after a failed attempt (name taken, - // e-mail in use…) it is spent, so clear it and make the user solve a - // fresh one before the next try. No-op when no captcha is configured. - captcha.reset(); + setError(errorText(err)); + captcha.renew(); } finally { setLoading(false); } @@ -609,9 +595,8 @@ export function RegisterPage() { onChange=${e => setEmailRecovery(e.target.checked)} /> <span>${t('register.recovery_email_opt')}</span> </label> - ${captcha.widget} ${error && html`<div class="error-msg">${error}</div>`} - <button type="submit" disabled=${loading || (captcha.enabled && !captcha.token)}> + <button type="submit" disabled=${loading}> ${loading ? t('register.loading') : t('register.submit')} </button> <p class="register-legal"> @@ -658,12 +643,13 @@ export function ResetPasswordPage({ onLogin }) { method: 'POST', body: { username: username.trim(), email: email.trim(), - captcha_token: captcha.token, + captcha_token: await captcha.take(), }, }); setPhase('form'); } catch (err) { - setError(err.message); + setError(errorText(err)); + captcha.renew(); } finally { setBusy(false); } @@ -771,9 +757,8 @@ export function ResetPasswordPage({ onLogin }) { <input type="email" placeholder="${t('register.email')}" value=${email} onInput=${e => setEmail(e.target.value)} autocomplete="email" required /> - ${captcha.widget} ${error && html`<div class="error-msg">${error}</div>`} - <button type="submit" disabled=${busy || (captcha.enabled && !captcha.token)}> + <button type="submit" disabled=${busy}> ${t('reset.send_code')}</button> </form>`} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/captcha.js b/packages/meshbay-hub/src/meshbay_hub/static/captcha.js new file mode 100644 index 0000000..bc174a0 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/captcha.js @@ -0,0 +1,47 @@ +// The proof of work that gates sign-up and passphrase reset (the hub's +// captcha.py). The hub sends the SHA-256 of `<prefix><number>` and the page +// finds the number by trying them from 0, so a person waits for nothing: it is +// solved while the form is being filled in. Nothing leaves for a third party. +// +// On the main thread, in batches handed to SubtleCrypto, rather than in a Web +// Worker: the Android shell serves the interface through its own request +// interceptor, and a worker's script is a request that interceptor is not +// guaranteed to see. Each batch is a turn of the event loop, so typing in the +// form is not held up by it. + +const BATCH = 1000; + +function fromHex(hex) { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); + return out; +} + +function same(buf, want) { + const got = new Uint8Array(buf); + for (let i = 0; i < want.length; i++) if (got[i] !== want[i]) return false; + return true; +} + +/** + * The answer to a challenge from `GET /v1/users/captcha`, as the token the + * register and reset-request bodies carry — or null if no number below + * `max_number` matches, which only a challenge that is not the hub's produces, + * or once `signal` is aborted: the form was left before it was needed. + */ +export async function solve({ prefix, challenge, max_number: max, signature }, signal) { + const enc = new TextEncoder(); + const want = fromHex(challenge); + for (let start = 0; start < max; start += BATCH) { + if (signal && signal.aborted) return null; + const end = Math.min(start + BATCH, max); + const pending = []; + for (let n = start; n < end; n++) { + pending.push(crypto.subtle.digest('SHA-256', enc.encode(prefix + n))); + } + const digests = await Promise.all(pending); + const hit = digests.findIndex(d => same(d, want)); + if (hit >= 0) return `${prefix}${start + hit}.${signature}`; + } + return null; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index edf4df9..bd5282c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -401,9 +401,9 @@ async function registerUser(username, email, password, recoveryMnemonic, captcha // appends it to the verification e-mail and stores it nowhere // (docs/MESHBAY_DESIGN.md §3.6). Omitted when they chose to save it themselves. if (recoveryMnemonic) payload.recovery_key = recoveryMnemonic; - // reCAPTCHA response, when the hub has a captcha configured. The widget lives - // in RegisterPage (auth-page.js); this function just forwards its token. A - // hub with no captcha configured sends nothing and the server does not check. + // The proof-of-work answer, when the hub asks for one. RegisterPage + // (auth-page.js) works it out; this function just forwards it. A hub with + // the check turned off gets nothing and does not look. if (captchaToken) payload.captcha_token = captchaToken; const resp = await hubCall('/v1/users/register', { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 1fe2b36..bf16bca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -139,8 +139,7 @@ export default { 'register.strength': 'Stärke: etwa {bits} Bit. Sie schützt die Kopie Ihrer ' + 'Schlüssel, die auf den Nodes liegt, denen Sie beitreten — es lohnt sich also, ' + 'sie gut zu wählen.', - 'captcha.required': 'Bitte das Captcha ausfüllen.', - 'captcha.failed': 'Captcha-Überprüfung fehlgeschlagen — bitte erneut versuchen.', + 'captcha.failed': "Die Prüfung gegen Roboter ist fehlgeschlagen. Bitte versuchen Sie es erneut.", // Home 'home.welcome': 'Willkommen bei MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 642d3e2..c7dc47e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -140,8 +140,7 @@ export default { + 'your keys where they are stored — a few unrelated words work well.', 'register.strength': 'Strength: about {bits} bits. This protects the copy of ' + 'your keys kept on the nodes you join, so it is worth getting right.', - 'captcha.required': 'Please complete the captcha.', - 'captcha.failed': 'Captcha verification failed — please try again.', + 'captcha.failed': "The anti-robot check failed. Please try again.", // Home 'home.welcome': 'Welcome to MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index fe66af4..0831bac 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -137,8 +137,7 @@ export default { + 'relación entre sí funcionan muy bien.', 'register.strength': 'Solidez: unos {bits} bits. Protege la copia de sus claves que ' + 'se guarda en los nodes a los que se une, así que conviene acertar.', - 'captcha.required': 'Por favor, complete el captcha.', - 'captcha.failed': 'Verificación del captcha fallida — inténtelo de nuevo.', + 'captcha.failed': "La verificación anti-robots ha fallado. Inténtelo de nuevo.", // Home 'home.welcome': 'Bienvenido a MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 6296a8b..2a52848 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -138,8 +138,7 @@ export default { 'register.strength': 'Robustesse : environ {bits} bits. Elle protège la copie de ' + 'vos clés conservée sur les nodes que vous rejoignez, cela vaut donc la peine ' + 'de la soigner.', - 'captcha.required': 'Veuillez compléter le captcha.', - 'captcha.failed': 'Vérification du captcha échouée — veuillez réessayer.', + 'captcha.failed': "La vérification anti-robot a échoué. Veuillez réessayer.", // Home 'home.welcome': 'Bienvenue sur MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 0751692..1dd3f52 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -138,8 +138,7 @@ export default { + 'legame tra loro funziona bene.', 'register.strength': 'Robustezza: circa {bits} bit. Protegge la copia delle sue ' + 'chiavi conservata sui node a cui aderisce, quindi vale la pena sceglierla con cura.', - 'captcha.required': 'Completa il captcha.', - 'captcha.failed': 'Verifica captcha fallita — riprova.', + 'captcha.failed': "La verifica anti-robot non è riuscita. Riprovi.", // Home 'home.welcome': 'Benvenuto in MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index c3d4fa8..0d4aadf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -136,8 +136,7 @@ export default { + 'その鍵を守るものです。互いに関係のない単語をいくつか並べると効果的です。', 'register.strength': '強度:約 {bits} ビット。参加している各 node に保管される鍵の' + 'コピーを守るものですので、しっかり決めておく価値があります。', - 'captcha.required': 'キャプチャを完了してください。', - 'captcha.failed': 'キャプチャの検証に失敗しました — もう一度お試しください。', + 'captcha.failed': "ロボット対策の確認に失敗しました。もう一度お試しください。", // Home 'home.welcome': 'MeshBay へようこそ', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index c02be99..31fc7b3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -139,8 +139,7 @@ export default { 'register.strength': 'Sterkte: ongeveer {bits} bits. Ze beschermt de kopie van uw ' + 'sleutels die op de nodes staat waar u zich bij aansluit, dus het loont om er ' + 'even bij stil te staan.', - 'captcha.required': 'Vul de captcha in.', - 'captcha.failed': 'Captchaverificatie mislukt — probeer het opnieuw.', + 'captcha.failed': "De controle tegen robots is mislukt. Probeer het opnieuw.", // Home 'home.welcome': 'Welkom bij MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 97e8b53..e88272d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -143,8 +143,7 @@ export default { + 'bardzo dobrze.', 'register.strength': 'Siła: około {bits} bitów. Chroni kopię kluczy przechowywaną na ' + 'nodes, do których się dołącza, więc warto ją dobrać starannie.', - 'captcha.required': 'Proszę wypełnić captcha.', - 'captcha.failed': 'Weryfikacja captcha nie powiodła się — spróbuj ponownie.', + 'captcha.failed': "Weryfikacja antyrobotowa nie powiodła się. Spróbuj ponownie.", // Home 'home.welcome': 'Witamy w MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 8ff36a5..5b2d80f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -139,8 +139,7 @@ export default { + 'entre si funcionam muito bem.', 'register.strength': 'Robustez: cerca de {bits} bits. Ela protege a cópia das suas ' + 'chaves mantida nos nodes dos quais você participa, então vale a pena caprichar.', - 'captcha.required': 'Por favor, complete o captcha.', - 'captcha.failed': 'Verificação do captcha falhou — tente novamente.', + 'captcha.failed': "A verificação anti-robô falhou. Tente novamente.", // Home 'home.welcome': 'Bem-vindo ao MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index d4ad64f..a6ea535 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -136,8 +136,7 @@ export default { + '几个互不相关的词就很好用。', 'register.strength': '强度:约 {bits} 位。它保护的是您所加入的各个 node 上保存的密钥副本,' + '因此值得认真设置。', - 'captcha.required': '请完成验证码。', - 'captcha.failed': '验证码验证失败——请重试。', + 'captcha.failed': "防机器人验证失败,请重试。", // Home 'home.welcome': '欢迎使用 MeshBay', diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py index bb0ed28..5104ae2 100644 --- a/packages/meshbay-hub/tests/conftest.py +++ b/packages/meshbay-hub/tests/conftest.py @@ -29,6 +29,7 @@ def hub_key_path(tmp_path_factory) -> Path: @pytest.fixture def hub_config(hub_key_path, tmp_path): from meshbay_hub.config import ( + CaptchaConfig, DatabaseConfig, HubConfig, HubIdentityConfig, @@ -40,6 +41,9 @@ def hub_config(hub_key_path, tmp_path): server=ServerConfig(host="127.0.0.1", port=8000), identity=HubIdentityConfig(id="test-hub", private_key_path=hub_key_path), jwt=JWTConfig(access_token_ttl=3600), + # Off here, so every test that registers is not also solving a proof of + # work; test_register_captcha.py turns it on. + captcha=CaptchaConfig(enabled=False), ) return cfg diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py index cd5a36e..1049a5f 100644 --- a/packages/meshbay-hub/tests/test_android_shell.py +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -71,7 +71,6 @@ def test_the_policy_is_the_desktop_policy_sent_as_a_header(): body = text.split(start, 1)[1].split(end, 1)[0] out = {} for d in re.findall(r"[`\"]([a-z-]+(?: [^`\"]*)?)[`\"]", body): - d = d.replace("${RECAPTCHA_SRC}", "$RECAPTCHA_SRC") out[d.split()[0]] = d return out @@ -82,8 +81,6 @@ def test_the_policy_is_the_desktop_policy_sent_as_a_header(): assets = _read(SRC / "shell" / "UiAssets.kt") assert '"Content-Security-Policy" to CSP' in assets - recaptcha = 'RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"' - assert recaptcha in assets markup = re.sub(r"<!--.*?-->", "", _read(CLIENT / "scripts" / "index.html"), flags=re.S) assert "Content-Security-Policy" not in markup diff --git a/packages/meshbay-hub/tests/test_captcha_host_check.py b/packages/meshbay-hub/tests/test_captcha_host_check.py deleted file mode 100644 index a0ff509..0000000 --- a/packages/meshbay-hub/tests/test_captcha_host_check.py +++ /dev/null @@ -1,252 +0,0 @@ -"""A solved captcha has to have been solved on one of our own pages. - -reCAPTCHA normally does this itself: the widget reports the hostname of the -page it is rendered in, and Google compares it against the key's domain list. -That is the check the desktop client cannot pass. Its interface ships inside -the package and is served from `app://meshbay` — not a preference, Chromium -refuses a service worker on a custom scheme and the hub must never become the -document origin — so the hostname Google sees is not the hub's and never can -be. The widget renders "ERROR for site owner: Invalid domain for site key", -which no amount of client-side configuration reaches, because the decision is -made on Google's servers. - -Turning the key's own origin check off moves the decision here. `siteverify` -then reports the hostname it *observed* — not one the caller asserts — so this -stays a real check: a bot that embeds the site key on a page of its own is -refused on the hostname it actually served from, which is the attack that -turning the check off would otherwise open. - -`allowed_hosts` empty means "do not check", which is what a deployment that -left the origin check with Google wants. That is the default, so an existing -hub upgrades without its captcha changing behaviour. - -The desktop client needed one more thing, found only by trying it. Its origin -is `app://meshbay`, and the guess was that Google would report the host part, -`meshbay`. It does not: a solve it cannot attribute to a domain comes back -with an **empty** hostname. `allow_unattributed_host` is what admits those, -and it is a named flag rather than an allowlist entry because a blank in a -TOML list is a typo far more often than an intention. -""" - -import json - -import pytest -from meshbay_hub.captcha import verify_captcha -from meshbay_hub.config import CaptchaConfig, load_config - - -class _Resp: - def __init__(self, payload): - self._payload = payload - - def raise_for_status(self): - return None - - def json(self): - return json.loads(json.dumps(self._payload)) - - -class _Client: - """Stands in for httpx.AsyncClient, and records what was sent.""" - - sent: list[dict] = [] - - def __init__(self, payload): - self._payload = payload - - def __call__(self, *a, **kw): - return self - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - async def post(self, url, data=None): - type(self).sent.append(dict(data or {})) - return _Resp(self._payload) - - -@pytest.fixture -def google(monkeypatch): - """`siteverify` answers whatever the test says it answers.""" - def _answer(**payload): - _Client.sent = [] - monkeypatch.setattr("httpx.AsyncClient", _Client(payload)) - return _answer - - -@pytest.mark.asyncio -async def test_a_solve_from_an_expected_host_is_accepted(google): - google(success=True, hostname="meshbay.org") - assert await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"})) - - -@pytest.mark.asyncio -async def test_an_unattributed_solve_is_refused_by_default(google): - """What the desktop client actually produces, and what it costs. - - `app://meshbay` does not report `meshbay`. Google attributes the solve to - no domain at all and the hostname comes back **empty** — measured live, - after the first attempt to allowlist `meshbay` failed. Refusing by default - is right: an answer with no hostname must not read as "any hostname". - """ - google(success=True, hostname="") - assert not await verify_captcha( - "s", "t", allowed_hosts=frozenset({"meshbay.org", "localhost"})) - - -@pytest.mark.asyncio -async def test_the_flag_admits_it_and_the_allowlist_is_not_how(google): - """The flag is the only way in, on purpose. - - An empty allowlist entry would be the obvious alternative and is a bad - one: a blank in a TOML list is a typo far more often than an intention, - and `load_config` drops blanks for exactly that reason. So this must not - work by accident from an allowlist alone. - """ - google(success=True, hostname="") - allowed = frozenset({"meshbay.org", "localhost"}) - assert await verify_captcha("s", "t", allowed_hosts=allowed, allow_unattributed=True) - assert not await verify_captcha("s", "t", allowed_hosts=allowed | {""}) - - -@pytest.mark.asyncio -async def test_the_flag_does_not_open_the_allowlist(google): - """It admits *unattributed* solves, not solves from somewhere else. - - A bot on its own web page still reports that page's hostname, which is - still not ours, flag or no flag. - """ - google(success=True, hostname="a-bot-farm.example") - assert not await verify_captcha( - "s", "t", allowed_hosts=frozenset({"meshbay.org"}), allow_unattributed=True) - - -@pytest.mark.asyncio -async def test_a_solve_from_somewhere_else_is_refused(google): - """The attack that turning off the key's origin check would open: the site - key is public, so a bot can render the widget on a page of its own.""" - google(success=True, hostname="a-bot-farm.example") - assert not await verify_captcha( - "s", "t", allowed_hosts=frozenset({"meshbay.org", "meshbay"})) - - -@pytest.mark.asyncio -async def test_an_absent_hostname_field_reads_like_an_empty_one(google): - """`hostname` missing and `hostname: ""` are the same answer.""" - google(success=True) - allowed = frozenset({"meshbay.org"}) - assert not await verify_captcha("s", "t", allowed_hosts=allowed) - assert await verify_captcha("s", "t", allowed_hosts=allowed, allow_unattributed=True) - - -@pytest.mark.asyncio -async def test_no_allowed_hosts_means_google_is_still_the_one_checking(google): - """The default, and what every hub that never touched this config does. - - The hostname is whatever it is and this does not look: the key's own origin - check is on, so the answer would not have come back successful otherwise. - """ - google(success=True, hostname="somewhere.example") - assert await verify_captcha("s", "t") - assert await verify_captcha("s", "t", allowed_hosts=None) - - -@pytest.mark.asyncio -async def test_a_failed_solve_is_refused_whatever_the_hostname(google): - """Order matters: `success` first, and the host check never rescues a no.""" - google(success=False, hostname="meshbay.org", **{"error-codes": ["timeout-or-duplicate"]}) - assert not await verify_captcha( - "s", "t", allowed_hosts=frozenset({"meshbay.org"})) - - -@pytest.mark.asyncio -async def test_the_remote_ip_still_rides_along(google): - """Unchanged behaviour, pinned because the signature grew a parameter.""" - google(success=True, hostname="meshbay.org") - await verify_captcha("s", "t", "203.0.113.7", frozenset({"meshbay.org"})) - assert _Client.sent == [{"secret": "s", "response": "t", "remoteip": "203.0.113.7"}] - - -@pytest.mark.asyncio -async def test_a_transport_failure_is_a_refusal_not_an_exception(monkeypatch): - """Unchanged, and worth keeping: a captcha that cannot be checked is not a - captcha that passed.""" - class _Boom(_Client): - async def post(self, url, data=None): - raise RuntimeError("no route to host") - - monkeypatch.setattr("httpx.AsyncClient", _Boom({})) - assert not await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"})) - - -@pytest.mark.asyncio -async def test_the_flag_alone_checks_nothing_without_an_allowlist(google): - """`allowed_hosts` empty still means "do not check", flag or no flag — - the whole block is skipped and reCAPTCHA is doing the origin check.""" - google(success=True, hostname="somewhere.example") - assert await verify_captcha("s", "t", allow_unattributed=False) - assert await verify_captcha("s", "t", allow_unattributed=True) - - -def test_the_config_hands_over_a_set_or_nothing(): - """`host_check` is what the caller passes straight through, so "unset" has - to arrive as None rather than an empty set that refuses everything.""" - assert CaptchaConfig().host_check is None - assert CaptchaConfig(allowed_hosts=[]).host_check is None - assert CaptchaConfig(allowed_hosts=["meshbay.org", "meshbay"]).host_check == frozenset( - {"meshbay.org", "meshbay"}) - - -def test_hub_toml_carries_the_allowed_hosts(tmp_path): - """An operator who turns off the key's origin check sets this, and it is - the only place they can: the value depends on the deployment.""" - cfg_file = tmp_path / "hub.toml" - cfg_file.write_text( - '[captcha]\n' - 'site_key = "6Lsite"\n' - 'secret_key = "6Lsecret"\n' - 'allowed_hosts = ["meshbay.org", " meshbay ", "", "localhost"]\n', encoding="utf-8") - - cfg = load_config(cfg_file) - - assert cfg.captcha.enabled - assert cfg.captcha.host_check == frozenset({"meshbay.org", "meshbay", "localhost"}), ( - "entries are stripped and blanks dropped — a stray space in a TOML " - "list would otherwise refuse every solve from that host") - - -def test_a_hub_toml_without_the_key_checks_nothing(tmp_path): - """The upgrade path. A hub that never heard of this setting keeps the - behaviour it has, with reCAPTCHA doing the origin check.""" - cfg_file = tmp_path / "hub.toml" - cfg_file.write_text('[captcha]\nsite_key = "6Lsite"\nsecret_key = "6Lsecret"\n', - encoding="utf-8") - - assert load_config(cfg_file).captcha.host_check is None - - -def test_the_unattributed_flag_comes_from_the_config(tmp_path, monkeypatch): - cfg_file = tmp_path / "hub.toml" - cfg_file.write_text( - '[captcha]\nsite_key = "k"\nsecret_key = "s"\n' - 'allowed_hosts = ["meshbay.org"]\nallow_unattributed_host = true\n', encoding="utf-8") - assert load_config(cfg_file).captcha.allow_unattributed_host is True - - cfg_file.write_text('[captcha]\nsite_key = "k"\nsecret_key = "s"\n', encoding="utf-8") - assert load_config(cfg_file).captcha.allow_unattributed_host is False, ( - "the default has to stay off — it is the looser of the two") - - monkeypatch.setenv("MESHBAY_CAPTCHA_ALLOW_UNATTRIBUTED_HOST", "true") - assert load_config(cfg_file).captcha.allow_unattributed_host is True - - -def test_the_environment_can_override_the_list(monkeypatch, tmp_path): - """Same shape as MESHBAY_ADMIN_USERS, for a container that has no file.""" - monkeypatch.setenv("MESHBAY_CAPTCHA_ALLOWED_HOSTS", "meshbay.org, meshbay ,") - - cfg = load_config(tmp_path / "absent.toml") - - assert cfg.captcha.host_check == frozenset({"meshbay.org", "meshbay"}) diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index c2ea4ca..7a26dfb 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -197,14 +197,9 @@ def _policy() -> str: """ import re source = _main() - # The array mixes plain strings and one `${RECAPTCHA_SRC}` template literal; - # resolve the constant so every directive reads as plain text. - rec = re.search(r"const RECAPTCHA_SRC = '([^']*)'", source) match = re.search(r"const CSP = \[(.*?)\]\.join", source, re.S) assert match, "no CSP constant in the main process" body = match.group(1) - if rec: - body = body.replace("${RECAPTCHA_SRC}", rec.group(1)) # Skip the `//` comments inside the array. Reading one as a directive is # the mistake this file already records against the packaged unit test, # which matched the comment explaining why `User=` was absent: parse @@ -226,24 +221,22 @@ def _directive(name: str) -> str: def test_the_hub_is_reachable_but_never_executable(): """ - The page connects to its own files and to reCAPTCHA, nothing else: every + The page connects to its own files, nothing else: every hub call leaves from the main process (a page on `app://` is refused by CORS anyway), and the node arrives over WebRTC, which connect-src does not govern. A bare `https:` there let injected script post anything anywhere. script-src never names the hub: nothing it returns is ever executed. The - only script sources are 'self', the wasm eval token, and the two reCAPTCHA - hosts (see the next test) — never a bare `https:` scheme, which would let - the hub's own origin serve script. + only script sources are 'self' and the wasm eval token — never a bare + `https:` scheme, which would let the hub's own origin serve script. """ connect = _directive("connect-src").split()[1:] - assert set(connect) == {"'self'", "https://www.google.com", "https://www.gstatic.com"} + assert set(connect) == {"'self'"} script = _directive("script-src") assert script, "no script-src directive" sources = script.split()[1:] # drop the "script-src" keyword itself allowed = { "'self'", "'wasm-unsafe-eval'", - "https://www.google.com", "https://www.gstatic.com", } assert set(sources) <= allowed, \ f"unexpected script-src source: {set(sources) - allowed}" @@ -252,22 +245,17 @@ def test_the_hub_is_reachable_but_never_executable(): assert "default-src 'none'" in _policy() -def test_recaptcha_is_the_only_third_party_and_stays_scoped_to_it(): +def test_no_third_party_origin_in_the_policy(): """ - reCAPTCHA gates sign-up in the app the same way it does in the browser. - www.google.com and www.gstatic.com are allowed under script-src, frame-src - and img-src for that — and no other external origin appears anywhere in the - policy. Remove this expectation only alongside the reCAPTCHA widget. + Nothing outside the application's own files is reachable from the page. + The one exception there was, reCAPTCHA's two Google origins under + script-src, frame-src and img-src, went with the captcha: sign-up is gated + by a proof of work the hub issues itself (meshbay_hub/captcha.py). """ - hosts = {"https://www.google.com", "https://www.gstatic.com"} - for directive in ("script-src", "frame-src", "img-src"): - srcs = set(_directive(directive).split()[1:]) - assert hosts <= srcs, f"{directive} is missing a reCAPTCHA host" - for part in _policy().split(";"): for tok in part.strip().split()[1:]: - if tok.startswith(("http://", "https://")): - assert tok in hosts, f"unexpected external origin in CSP: {tok}" + assert not tok.startswith(("http://", "https://")), ( + f"external origin in CSP: {tok}") def test_the_pdf_preview_has_both_permissions_it_needs(): diff --git a/packages/meshbay-hub/tests/test_register_captcha.py b/packages/meshbay-hub/tests/test_register_captcha.py index 5f94b9c..b63b197 100644 --- a/packages/meshbay-hub/tests/test_register_captcha.py +++ b/packages/meshbay-hub/tests/test_register_captcha.py @@ -1,27 +1,49 @@ -"""Registration CAPTCHA is enforced for every fresh account when configured. +"""Sign-up and passphrase reset are gated by the hub's own proof of work. -The gate used to be skipped whenever the request carried an `auth_key` — which -every real client sends (the password split) — so it protected nobody and a bot -skipped it by including the field. It now runs on `captcha.enabled` alone; the -desktop client is Chromium and renders the same widget. +The gate replaced reCAPTCHA, which sent every visitor's address and browser +details to Google and needed a consent the sign-up form could not ask for +freely. What it must still be: unconditional when enabled — the old gate was +once skipped for any request carrying an `auth_key`, which every real client +sends — and an answer the hub issued, in date, and used once. + +The last test runs the page's own solver (`static/captcha.js`) against a +challenge from `captcha.issue`, so the two halves cannot drift apart unseen. """ +import hashlib +import json +import shutil +import subprocess +import time +from pathlib import Path + import pytest +from meshbay_hub import captcha +from meshbay_hub.config import load_config + +STATIC = Path(captcha.__file__).parent / "static" @pytest.fixture def captcha_on(client, monkeypatch): - """Turn on a fake captcha: any config with both keys is `enabled`, and - verification succeeds only for the token 'good-token'.""" + """The check on, with little enough work that the tests solve it at once.""" from meshbay_hub.api.users import _cfg - monkeypatch.setattr(_cfg.captcha, "site_key", "test-site") - monkeypatch.setattr(_cfg.captcha, "secret_key", "test-secret") + monkeypatch.setattr(_cfg.captcha, "enabled", True) + monkeypatch.setattr(_cfg.captcha, "max_number", 2000) + monkeypatch.setattr(captcha, "_spent", {}) + - async def fake_verify(secret, token, remote_ip=None, allowed_hosts=None, - allow_unattributed=False): - return token == "good-token" +def _solve(ch: dict) -> str: + for n in range(ch["max_number"]): + if hashlib.sha256(f"{ch['prefix']}{n}".encode()).hexdigest() == ch["challenge"]: + return f"{ch['prefix']}{n}.{ch['signature']}" + raise AssertionError("no number matches the challenge") - monkeypatch.setattr("meshbay_hub.captcha.verify_captcha", fake_verify) + +async def _token(client) -> str: + r = await client.get("/v1/users/captcha") + assert r.status_code == 200, r.text + return _solve(r.json()) def _body(**over): @@ -31,29 +53,111 @@ def _body(**over): @pytest.mark.asyncio -async def test_missing_captcha_rejected_even_with_auth_key(client, captcha_on): +async def test_missing_answer_rejected_even_with_auth_key(client, captcha_on): r = await client.post("/v1/users/register", json=_body()) assert r.status_code == 400 assert r.json()["detail"] == "captcha_required" @pytest.mark.asyncio -async def test_bad_captcha_rejected(client, captcha_on): +async def test_solved_challenge_accepted(client, captcha_on): r = await client.post("/v1/users/register", - json=_body(captcha_token="wrong")) + json=_body(captcha_token=await _token(client))) + assert r.status_code == 201, r.text + + +@pytest.mark.asyncio +async def test_an_answer_is_good_for_one_form(client, captcha_on): + token = await _token(client) + r = await client.post("/v1/users/register", json=_body(captcha_token=token)) + assert r.status_code == 201, r.text + r = await client.post("/v1/users/register", json=_body( + username="another_test", email="another@t.com", captcha_token=token)) assert r.status_code == 400 assert r.json()["detail"] == "captcha_failed" @pytest.mark.asyncio -async def test_good_captcha_accepted(client, captcha_on): - r = await client.post("/v1/users/register", - json=_body(captcha_token="good-token")) - assert r.status_code == 201 +async def test_a_wrong_number_is_refused(client, captcha_on): + nonce, expires, number, sig = (await _token(client)).split(".") + forged = f"{nonce}.{expires}.{int(number) + 1}.{sig}" + r = await client.post("/v1/users/register", json=_body(captcha_token=forged)) + assert r.json()["detail"] == "captcha_failed" + + +@pytest.mark.asyncio +async def test_a_challenge_the_hub_did_not_sign_is_refused(client, captcha_on): + """Somebody minting their own easy challenge has no key to sign it with.""" + forged = f"{'00' * 12}.{int(time.time()) + 60}.0.{'0' * 64}" + r = await client.post("/v1/users/register", json=_body(captcha_token=forged)) + assert r.json()["detail"] == "captcha_failed" @pytest.mark.asyncio -async def test_no_captcha_configured_still_registers(client): - # Default test config has no captcha keys — registration proceeds without one. +async def test_an_expired_answer_is_refused(client, captcha_on, monkeypatch): + token = await _token(client) + later = time.time() + captcha.CHALLENGE_TTL + 1 + monkeypatch.setattr(captcha.time, "time", lambda: later) + r = await client.post("/v1/users/register", json=_body(captcha_token=token)) + assert r.json()["detail"] == "captcha_failed" + + +@pytest.mark.asyncio +async def test_password_reset_request_is_gated_too(client, captcha_on): + body = {"username": "nobody_test", "email": "nobody@t.com"} + r = await client.post("/v1/users/password/reset-request", json=body) + assert r.json()["detail"] == "captcha_required" + r = await client.post("/v1/users/password/reset-request", + json={**body, "captcha_token": await _token(client)}) + assert r.status_code == 200, r.text + + +@pytest.mark.asyncio +async def test_turned_off_it_issues_nothing_and_checks_nothing(client): + # The test configuration has it off (conftest.py). + r = await client.get("/v1/users/captcha") + assert r.status_code == 404 + assert r.json()["detail"] == "captcha_disabled" r = await client.post("/v1/users/register", json=_body()) assert r.status_code == 201 + + +def test_on_unless_the_configuration_says_otherwise(tmp_path): + cfg_file = tmp_path / "hub.toml" + cfg_file.write_text('[server]\nport = 8000\n', encoding="utf-8") + assert load_config(cfg_file).captcha.enabled + cfg_file.write_text('[captcha]\nenabled = false\n', encoding="utf-8") + assert not load_config(cfg_file).captcha.enabled + + +def test_old_recaptcha_keys_are_ignored_with_a_warning(tmp_path, monkeypatch): + # Caught at the logger rather than through caplog: other tests in the suite + # reconfigure logging, and caplog then sees nothing. + from meshbay_hub import config + warned = [] + monkeypatch.setattr(config.log, "warning", lambda msg, *a: warned.append(msg)) + cfg_file = tmp_path / "hub.toml" + cfg_file.write_text('[captcha]\nsite_key = "6Lsite"\nsecret_key = "6Lsecret"\n', + encoding="utf-8") + cfg = load_config(cfg_file) + assert cfg.captcha.enabled + assert any("no longer read" in m for m in warned) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is not available") +@pytest.mark.asyncio +async def test_the_page_solver_answers_the_hub(client, captcha_on, tmp_path): + ch = (await client.get("/v1/users/captcha")).json() + (tmp_path / "package.json").write_text('{"type":"module"}', encoding="utf-8") + (tmp_path / "captcha.js").write_text( + (STATIC / "captcha.js").read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / "case.js").write_text( + "import { solve } from './captcha.js';\n" + f"console.log(JSON.stringify(await solve({json.dumps(ch)})));\n", encoding="utf-8") + proc = subprocess.run(["node", "case.js"], cwd=tmp_path, + capture_output=True, text=True, encoding="utf-8") + assert proc.returncode == 0, proc.stderr + token = json.loads(proc.stdout) + assert token == _solve(ch) + r = await client.post("/v1/users/register", json=_body(captcha_token=token)) + assert r.status_code == 201, r.text diff --git a/packages/meshbay-hub/tests/test_security_headers.py b/packages/meshbay-hub/tests/test_security_headers.py index b068656..e6d431e 100644 --- a/packages/meshbay-hub/tests/test_security_headers.py +++ b/packages/meshbay-hub/tests/test_security_headers.py @@ -64,12 +64,12 @@ def test_the_policy_is_locked_down_where_it_matters(): assert "https:" not in script.split() -def test_recaptcha_is_the_only_external_origin(): - hosts = {"https://www.google.com", "https://www.gstatic.com"} +def test_no_external_origin(): + """The sign-up check is the hub's own proof of work, not a third party's.""" for part in CSP.split(";"): for tok in part.strip().split()[1:]: - if tok.startswith(("http://", "https://")): - assert tok in hosts, f"unexpected external origin in CSP: {tok}" + assert not tok.startswith(("http://", "https://")), ( + f"external origin in CSP: {tok}") def test_the_streamed_download_frame_is_allowed(): @@ -78,8 +78,8 @@ def test_the_streamed_download_frame_is_allowed(): The streamed-download path works by navigating a hidden iframe to `/_mbdl/<id>` so the service worker is asked for the response it is already - holding. `frame-src` was tightened to reCAPTCHA's two origins when the - captcha needed a frame, and nobody connected the two: Chrome refused the + holding. `frame-src` was tightened to a third-party captcha's two origins + when it needed a frame, and nobody connected the two: Chrome refused the frame, the worker was never asked, and the page waited out its timeout for a download that could not happen. On Firefox and Safari that is the *only* way to write a large file to disk — there is no File System Access API and diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py index aabcf45..f78f327 100644 --- a/packages/meshbay-hub/tests/test_unauthenticated_surface.py +++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py @@ -27,10 +27,11 @@ PUBLIC = { ("GET", "/"): "the application shell", ("GET", "/app"): "the application shell", ("GET", "/app/{path:path}"): "the application shell", - ("GET", "/v1/hub/info"): "read before sign-in: captcha key, instance policy", + ("GET", "/v1/hub/info"): "read before sign-in: instance policy", ("GET", "/v1/hub/version"): "an installed client checks its minimum version first", ("GET", "/v1/hub/pubkey"): "nodes cache the hub key on first contact", ("GET", "/v1/health"): "supervision", + ("GET", "/v1/users/captcha"): "a proof-of-work challenge for sign-up and reset, per-IP limit", ("POST", "/v1/users/register"): "obtains an account — captcha, per-IP limit", ("POST", "/v1/users/login"): "obtains a session — per-IP limit, per-name lockout", ("POST", "/v1/users/auth"): "device sign-in — Ed25519 signature over a fresh timestamp", |