From 51705056a578d2649a989bf14a056fbcfae58a6b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 14 Oct 2026 13:34:47 +0200 Subject: 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 --- docs/MESHBAY_DESIGN.md | 20 +- docs/MESHBAY_HTTP_API.md | 1 + .../main/kotlin/org/meshbay/client/MainActivity.kt | 7 +- .../kotlin/org/meshbay/client/shell/UiAssets.kt | 14 +- packages/meshbay-client/src/main.js | 23 +- packages/meshbay-hub/src/meshbay_hub/api/hub.py | 10 - packages/meshbay-hub/src/meshbay_hub/api/users.py | 24 +- packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 23 +- packages/meshbay-hub/src/meshbay_hub/app.py | 2 - packages/meshbay-hub/src/meshbay_hub/captcha.py | 167 +++++++------- packages/meshbay-hub/src/meshbay_hub/config.py | 58 ++--- .../src/meshbay_hub/static/auth-page.js | 133 +++++------ .../meshbay-hub/src/meshbay_hub/static/captcha.js | 47 ++++ .../src/meshbay_hub/static/keyderive.js | 6 +- .../src/meshbay_hub/static/locales/de.js | 3 +- .../src/meshbay_hub/static/locales/en.js | 3 +- .../src/meshbay_hub/static/locales/es.js | 3 +- .../src/meshbay_hub/static/locales/fr.js | 3 +- .../src/meshbay_hub/static/locales/it.js | 3 +- .../src/meshbay_hub/static/locales/ja.js | 3 +- .../src/meshbay_hub/static/locales/nl.js | 3 +- .../src/meshbay_hub/static/locales/pl.js | 3 +- .../src/meshbay_hub/static/locales/pt-BR.js | 3 +- .../src/meshbay_hub/static/locales/zh-CN.js | 3 +- packages/meshbay-hub/tests/conftest.py | 4 + packages/meshbay-hub/tests/test_android_shell.py | 3 - .../meshbay-hub/tests/test_captcha_host_check.py | 252 --------------------- packages/meshbay-hub/tests/test_desktop_shell.py | 34 +-- .../meshbay-hub/tests/test_register_captcha.py | 148 ++++++++++-- .../meshbay-hub/tests/test_security_headers.py | 12 +- .../tests/test_unauthenticated_surface.py | 3 +- packaging/conf/hub.toml.example | 43 +--- 32 files changed, 435 insertions(+), 629 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/captcha.js delete mode 100644 packages/meshbay-hub/tests/test_captcha_host_check.py diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index f9ceaa2..2174879 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -2533,9 +2533,25 @@ Two things survive on purpose: the nodes refuse the account and close the groups' sessions now. A node that is offline misses it. -Registration is gated by a CAPTCHA whenever one is configured — **unconditionally**, +**Registration and the passphrase-reset request are gated by a proof of work** +(`captcha.py`), on unless `[captcha] enabled = false`, and **unconditionally** — not only when some other field is absent, or the real client's ordinary request -skips it. The desktop client renders the widget too. +skips it. The hub signs a challenge (the SHA-256 of a nonce, an expiry and a +number below `max_number`) with a key derived from its own private key; the page +finds the number while the form is being filled in and sends it with the form. +An answer is checked against the hub's signature, its expiry, and the answers +already spent, so one solve is one form. **No third party is involved**: no +script, cookie or visitor address leaves for anyone else, which is what keeps the +sign-up form free of a consent it could not ask for freely. What it buys is cost +per attempt, not a verdict on who is human; the bound that holds against a +caller with machines to spare is the mail budget (§7.4), since these two routes +are what make the hub send mail. + +**The sign-up form states what the hub keeps**, as a notice and never a checkbox: +the account rests on the terms of use and the connection log on a legal +obligation, so there is no consent to collect, and one that blocks sign-up would +be neither free nor withdrawable. The notice links to the legal pages of the hub +in use (`/legal/`), which are its operator's to write. **Sessions end three ways, all admin settings in hours** (`session.*`, §7.4): diff --git a/docs/MESHBAY_HTTP_API.md b/docs/MESHBAY_HTTP_API.md index 73d079d..be4e7e2 100644 --- a/docs/MESHBAY_HTTP_API.md +++ b/docs/MESHBAY_HTTP_API.md @@ -40,6 +40,7 @@ hub also serves the web application at `/` and `/app/`, which are not listed. | Method | Path | Auth | What | |---|---|---|---| +| GET | `/v1/users/captcha` | none | A proof-of-work challenge for the register and reset-request forms. | | POST | `/v1/users/register` | none | Create an account. | | POST | `/v1/users/verify-email` | none | Verify a registration email with the code received by mail. | | POST | `/v1/users/me/bundle-pepper` | user | The pepper, for a session that has just been given the passphrase again. | 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"} # `