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 --- 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 +- 19 files changed, 246 insertions(+), 254 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/captcha.js (limited to 'packages/meshbay-hub/src/meshbay_hub') 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"} # `