diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/captcha.md | 490 |
1 files changed, 490 insertions, 0 deletions
diff --git a/docs/captcha.md b/docs/captcha.md new file mode 100644 index 0000000..c908a5c --- /dev/null +++ b/docs/captcha.md @@ -0,0 +1,490 @@ +# reCAPTCHA on Registration and Password Reset + +> Goal: verify the user is not a bot **before** sending any email — registration +> verification code or password reset code. The captcha gate sits between form +> submission and the email-sending call, so a failed check never triggers an email. + +--- + +## 1. reCAPTCHA v2 (checkbox) + +reCAPTCHA v2 with the "I'm not a robot" checkbox. Reasons: + +- Binary pass/fail — no score threshold to tune or monitor. +- The user is already filling a form; one checkbox is negligible friction. +- Works in both the web SPA and the Electron client (both run Chromium). +- v3 (invisible, score-based) is an option later if the checkbox proves annoying; + the server-side verification call is identical, only the client widget differs. + +**Google Console setup:** create a reCAPTCHA v2 key pair at +`https://www.google.com/recaptcha/admin`. Register the hub's domain(s) — +`meshbay.org` and `localhost` for development. This produces a **site key** +(public, embedded in HTML) and a **secret key** (server-only, in `hub.toml`). + +--- + +## 2. Configuration + +### `hub.toml` + +```toml +[captcha] +site_key = "6Le..." # public — served to the frontend +secret_key = "6Le..." # private — never leaves the server +``` + +When the `[captcha]` section is absent or both keys are empty, the captcha is +**disabled** — the registration endpoint accepts requests without a token. This +keeps development, tests and self-hosted instances that do not need it +frictionless. + +### `config.py` — new dataclass + +```python +@dataclass +class CaptchaConfig: + site_key: str = "" + secret_key: str = "" + + @property + def enabled(self) -> bool: + return bool(self.site_key and self.secret_key) +``` + +Add `captcha: CaptchaConfig` to `HubConfig` (default: disabled). Parse the +`[captcha]` section in `load_config` on the same pattern as `[jwt]`: + +```python +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) +``` + +Environment variable overrides: `MESHBAY_CAPTCHA_SITE_KEY`, +`MESHBAY_CAPTCHA_SECRET_KEY`. + +--- + +## 3. Serving the site key to the frontend + +The site key is public and the SPA needs it before the user reaches the +registration form. Two options: + +**Option A — extend `/v1/hub/info`** (recommended). Add `captcha_site_key` to +the response (empty string when disabled). The SPA already calls this endpoint +at startup for `allow_public_groups`; no new request. The endpoint is +unauthenticated, which is correct — the site key is public by design. + +```python +# hub.py — hub_info() +return { + ... + "captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "", +} +``` + +**Option B — inject in the HTML shell.** Add a `<script>` line in `webapp.py`'s +`_HTML` template: `window.__MB_CAPTCHA_KEY = "{captcha_key}";`. Advantage: the +key is available synchronously, before any fetch. Disadvantage: `_HTML` is built +once at import time, so the config must be available then — currently it is, via +`set_config` in `app.py`. + +Recommendation: **option A**. It follows the existing pattern, avoids touching +the HTML shell, and the tiny latency of waiting for the `/v1/hub/info` response +is irrelevant — the user has not reached the form yet. + +--- + +## 4. Server-side verification + +### `captcha.py` (new module, `meshbay_hub/captcha.py`) + +```python +import httpx +import logging + +log = logging.getLogger(__name__) + +VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" + +async def verify_captcha(secret_key: str, token: str, remote_ip: str | None = None) -> bool: + payload = {"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 result.get("success", False) + except Exception: + log.exception("captcha verification request failed") + return False +``` + +Notes: +- `httpx` is already a dev dependency and is light. Add it to `[project.dependencies]` + in `packages/meshbay-hub/pyproject.toml`. +- 5-second timeout — a Google outage should not hang registration indefinitely. +- On network failure the function returns `False` (fail-closed). If this is too + aggressive for availability, a retry or a fallback to allowing registration + can be discussed — but for a bot-prevention gate, fail-closed is correct. +- `remote_ip` is optional — Google uses it for risk analysis, not as a hard check. + +### `users.py` — gate the registration endpoint + +Add `captcha_token: str | None = None` to `RegisterRequest`: + +```python +class RegisterRequest(BaseModel): + username: str + email: str + password: str | None = None + auth_key: str | None = None + captcha_token: str | None = None +``` + +In `register()`, **before** any database work: + +```python +@router.post("/register", status_code=201) +@limiter.limit("5/minute") +async def register(body: RegisterRequest, request: Request, db: AsyncSession = Depends(get_db)): + # ── Captcha gate ──────────────────────────────────────────────── + if _cfg and _cfg.captcha.enabled: + if not body.captcha_token: + raise HTTPException(400, "captcha_required") + from meshbay_hub.captcha import verify_captcha + ok = await verify_captcha( + _cfg.captcha.secret_key, + body.captcha_token, + request.client.host if request.client else None, + ) + if not ok: + raise HTTPException(400, "captcha_failed") + + # ── Existing registration logic (unchanged) ──────────────────── + eh = hash_email_blind(body.email) + ... +``` + +The check runs **before** the email blind hash, the username lookup, and the +Argon2id hash — none of which should execute for a bot. This also means a +failed captcha does not increment the rate limiter's cost beyond the existing +`5/minute` on the endpoint itself. + +### `users.py` — gate the password reset request + +Same principle: `POST /v1/users/password/reset-request` sends an email with a +6-digit code. The captcha must be verified before the email is sent. + +Add `captcha_token: str | None = None` to `ResetRequestRequest`: + +```python +class ResetRequestRequest(BaseModel): + username: str + email: str + captcha_token: str | None = None +``` + +In `password_reset_request()`, **before** the user lookup: + +```python +if _cfg and _cfg.captcha.enabled: + if not body.captcha_token: + raise HTTPException(400, "captcha_required") + from meshbay_hub.captcha import verify_captcha + ok = await verify_captcha( + _cfg.captcha.secret_key, + body.captcha_token, + request.client.host if request.client else None, + ) + if not ok: + raise HTTPException(400, "captcha_failed") +``` + +No `auth_key` exemption here: the reset form is web-only (it requires +`window.MeshBayKeys`), and the captcha gate applies to everyone on this path. + +--- + +## 5. Client-side implementation + +### Loading the reCAPTCHA script (`auth-page.js`) + +Load the script **lazily** when the registration form mounts, not in the HTML +shell — it is useless on every other page, and loading it globally adds ~150 KB +of Google JS to every visit. + +```javascript +function loadRecaptchaScript(siteKey) { + 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); +} +``` + +### Rendering the widget + +In `RegisterPage`, after the component mounts: + +```javascript +export function RegisterPage() { + const [captchaToken, setCaptchaToken] = useState(null); + const [captchaSiteKey, setCaptchaSiteKey] = useState(''); + const captchaRef = useRef(null); + const widgetId = useRef(null); + + useEffect(() => { + // Fetch the site key from /v1/hub/info (already cached by the SPA) + hubFetch('/v1/hub/info').then(info => { + if (info.captcha_site_key) { + setCaptchaSiteKey(info.captcha_site_key); + loadRecaptchaScript(); + } + }); + }, []); + + useEffect(() => { + if (!captchaSiteKey || !captchaRef.current) return; + const interval = setInterval(() => { + if (window.grecaptcha && window.grecaptcha.render) { + clearInterval(interval); + widgetId.current = window.grecaptcha.render(captchaRef.current, { + sitekey: captchaSiteKey, + callback: (token) => setCaptchaToken(token), + 'expired-callback': () => setCaptchaToken(null), + theme: document.documentElement.getAttribute('data-theme') === 'dark' + ? 'dark' : 'light', + }); + } + }, 100); + return () => clearInterval(interval); + }, [captchaSiteKey]); + ... +} +``` + +The widget div goes in the form, **above** the submit button: + +```javascript +${captchaSiteKey && html` + <div ref=${captchaRef} style="display:flex;justify-content:center;margin:12px 0"></div> +`} +``` + +### Sending the token + +In `onSubmit`, include `captcha_token` in the request body. Both paths +(native `MeshBayKeys.registerUser` and the web fallback) must send it: + +```javascript +// Web fallback path +await hubFetch('/v1/users/register', { + method: 'POST', + body: { username, email, password, captcha_token: captchaToken }, +}); +``` + +For the `MeshBayKeys.registerUser` path, `registerUser()` in `keyderive.js` +must accept and forward the token: + +```javascript +async registerUser(username, email, password, captchaToken) { + const authKey = await deriveAuthKey(password, username); + await hubFetch('/v1/users/register', { + method: 'POST', + body: { username, email, auth_key: authKey, captcha_token: captchaToken }, + }); +} +``` + +### Resend flow + +`onResend` re-POSTs to `/register`. On a resend, the captcha token has already +been consumed by Google (one-time use). Two options: + +1. **Reset the widget** after each submission (`grecaptcha.reset(widgetId)`) so + the user solves it again before resending. Safe but mildly annoying. +2. **Skip the captcha on resend** server-side — the account already exists in + `pending` state, proving it passed the captcha once. The server already + detects this case (`found.status == "pending" and found.email_hash == eh`, + `users.py:126`). Move the captcha gate to run only when no pending account + matches. + +Recommendation: **option 2** — skip the captcha when resending to an existing +pending account. The bot-prevention goal is met by the first check; a resend +is a human who lost the email. + +Server-side, restructure the check order: + +```python +# Check for existing pending account first (resend case) +existing = await db.execute(select(User).where(User.username == body.username)) +found = existing.scalar_one_or_none() +if found and found.status == "pending" and found.email_hash == eh: + # Resend — captcha already passed on initial registration + await _create_and_send_verification(db, found, body.email, eh) + await db.commit() + return {"user_id": str(found.id), "email_verification_required": True} + +# New registration — require captcha +if _cfg and _cfg.captcha.enabled: + if not body.captcha_token: + raise HTTPException(400, "captcha_required") + ... +``` + +### Password reset page (`ResetPasswordPage`) + +The same captcha widget is rendered in the `request` phase of +`ResetPasswordPage` — the form where the user enters username + email before a +reset code is sent. The implementation is identical: lazy-load the script, render +the widget, send `captcha_token` in the body of +`POST /v1/users/password/reset-request`. + +The `form` phase (entering the code + new passphrase) does **not** need a +captcha — the code itself is the proof the user controls the email. + +### Shared captcha helper + +Both `RegisterPage` and `ResetPasswordPage` need the same logic: load the +script, render the widget, track the token. Extract a reusable `useCaptcha()` +hook to avoid duplicating the setup code across both components. + +### Error handling + +The SPA must handle two new error codes from the server: + +- `captcha_required` — the server expects a captcha token but none was sent. + Display a message asking the user to complete the captcha. Should not happen + in normal flow unless JS failed to load. +- `captcha_failed` — the token was rejected. Reset the widget and ask the user + to try again. + +Add i18n keys: +``` +captcha.required: "Please complete the captcha" +captcha.failed: "Captcha verification failed — please try again" +``` + +--- + +## 6. Desktop client (Electron) + +The Electron client loads its UI from the package (`app://meshbay`), not from +the hub. Its CSP forbids external scripts by design — the renderer executes no +code that did not ship in the package. Loading Google's reCAPTCHA script +(`https://www.google.com/recaptcha/api.js`) would be a **new category of +trust**: third-party executable code in a renderer that currently runs none. + +Two approaches: + +- **Open the CSP to Google's reCAPTCHA domains.** Add + `https://www.google.com/recaptcha/` and `https://www.gstatic.com/recaptcha/` + to `script-src`, `frame-src` and `connect-src`. Functional, but undermines + the principle that the renderer runs only packaged code — the reCAPTCHA + script is fetched live and changes without the operator's knowledge. +- **Skip the captcha for native clients** (recommended). The Electron client + already raises the bar against automated account creation: it requires + installation, generates a device Ed25519 key pair stored in `safeStorage`, + and authenticates to the hub via `POST /v1/users/auth` with a signed + challenge. A bot automating that path must install and drive a full Electron + app, which is a harder problem than filling a web form — and the reCAPTCHA + exists to solve the web-form problem. + +**How to skip server-side.** The web SPA sends `password` (legacy) in the +registration body; the Electron client sends `auth_key` (PBKDF2-derived via +`window.MeshBayKeys.registerUser`). The server requires `captcha_token` only +when `auth_key` is absent — i.e. the web path. This is not a security +boundary: a bot that derives `auth_key` itself bypasses the check, but it also +proves it can run the PBKDF2 derivation, which is the same cost as solving the +captcha. The real gate for native-path abuse is the rate limiter (`5/minute`) +and the email verification step. + +```python +# In register(), captcha gate adjusted: +if _cfg and _cfg.captcha.enabled and not body.auth_key: + if not body.captcha_token: + raise HTTPException(400, "captcha_required") + ... +``` + +On the Electron side: `registerUser()` in `keyderive.js` does not send +`captcha_token`, and the server does not ask for one. No CSP change, no +Google script loaded, no new trust boundary. + +--- + +## 7. Dependencies + +Add `httpx` to `packages/meshbay-hub/pyproject.toml` runtime dependencies +(it is already in `[project.optional-dependencies] dev`): + +```toml +dependencies = [ + ... + "httpx>=0.28", +] +``` + +No other new dependency. The reCAPTCHA client-side is a single `<script>` tag +from Google — no npm package. + +--- + +## 8. Files changed + +| File | Change | +|---|---| +| `packages/meshbay-hub/src/meshbay_hub/config.py` | Add `CaptchaConfig` dataclass, `captcha` field on `HubConfig`, parse `[captcha]` section + env vars | +| `packages/meshbay-hub/src/meshbay_hub/captcha.py` | New module — `verify_captcha()` | +| `packages/meshbay-hub/src/meshbay_hub/api/users.py` | Add `captcha_token` to `RegisterRequest` and `ResetRequestRequest`, gate before email send on both endpoints | +| `packages/meshbay-hub/src/meshbay_hub/api/hub.py` | Add `captcha_site_key` to `/v1/hub/info` response | +| `packages/meshbay-hub/src/meshbay_hub/app.py` | Pass `cfg` to `hub.py` (for site key access) — may already be sufficient via `_cfg` | +| `packages/meshbay-hub/src/meshbay_hub/static/auth-page.js` | Load reCAPTCHA script, render widget in `RegisterPage` and `ResetPasswordPage`, send token, handle errors | +| `packages/meshbay-hub/src/meshbay_hub/static/locales/en.js` | Add captcha error i18n keys | +| `packages/meshbay-hub/src/meshbay_hub/static/locales/*.js` | Same keys in each locale | +| `packages/meshbay-hub/pyproject.toml` | `httpx` to runtime deps | + +--- + +## 9. Testing + +- **Unit test (`test_captcha.py`):** mock `httpx.AsyncClient.post` to return + `{"success": true}` / `{"success": false}`, verify `verify_captcha()` returns + the right bool. Test timeout and network-error handling (returns `False`). +- **Integration test (`test_register_captcha.py`):** with captcha enabled in + config, POST to `/v1/users/register` without `captcha_token` → 400 + `captcha_required`. With a mocked passing token → 201. With a mocked failing + token → 400 `captcha_failed`. Resend to an existing pending account without + token → 201 (skip). Same tests for `/v1/users/password/reset-request`. +- **SPA source test:** verify `auth-page.js` sends `captcha_token` in the + registration body (add to `test_transport_contracts.py` or equivalent). +- **Manual test:** deploy to a local hub with real Google keys, register from a + browser, confirm the widget appears and the email is only sent after solving it. + +--- + +## 10. Deployment steps + +1. Obtain reCAPTCHA v2 keys from Google (register `meshbay.org` + `localhost`). +2. Add to `/etc/meshbay/hub.toml` on the production server: + ```toml + [captcha] + site_key = "6Le..." + secret_key = "6Le..." + ``` +3. Deploy the new hub code (`deploy-hub.sh` — runs `alembic upgrade head` + + restart; no migration needed for this change). +4. Verify registration: open `https://meshbay.org/#/register`, confirm the + checkbox appears. Complete registration, confirm email arrives only after + solving the captcha. +5. Verify reset: open `https://meshbay.org/#/reset`, confirm the checkbox + appears. Request a reset code, confirm email arrives only after solving it. |