diff options
Diffstat (limited to 'docs/captcha.md')
| -rw-r--r-- | docs/captcha.md | 555 |
1 files changed, 0 insertions, 555 deletions
diff --git a/docs/captcha.md b/docs/captcha.md deleted file mode 100644 index 7013804..0000000 --- a/docs/captcha.md +++ /dev/null @@ -1,555 +0,0 @@ -# reCAPTCHA on Registration and Password Reset - -> **Superseded by `MESHBAY_DESIGN.md`.** This was the registration and reset captcha; its design -> content now lives in §7.7. -> -> It is kept because code comments, tests and other documents cite its -> sections and its labels, and because it records reasoning a synthesis -> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design -> document is right; where either disagrees with the code, the code is.** -> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its -> replacement, and §13 defines every label. - -> 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 the web SPA. It works in the Electron client too, but not for the - reason "both run Chromium" — reCAPTCHA validates the *domain*, not the - rendering engine, and the desktop client's is not the hub's. See §6. -- 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`). -If the desktop client is in use, also turn *off* "Verify the origin of -reCAPTCHA solutions" on that key and set `allowed_hosts` — §6 says why, and -what is given up. - ---- - -## 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), and the domain problem - -**This section replaced two earlier designs, and both are worth naming because -the reasoning that produced them is the trap.** - -The first said: open the CSP to Google's reCAPTCHA domains, or skip the captcha -for native clients — and recommended skipping it, keyed on `auth_key` being -present. That carve-out shipped and was a hole: *every* real client sends -`auth_key`, the browser included (it is the password split), so the gate was -off for everybody and a bot skipped it by including the field. It is gone; -`users.py` gates on `captcha.enabled` alone, and says so at the call site. - -The second is the sentence in §1 above: "works in the Electron client too, both -run Chromium". The CSP was opened (`RECAPTCHA_SRC` in `main.js`, covering -`script-src`, `img-src` and `frame-src`) and the widget does render. It renders -**"ERROR for site owner: Invalid domain for site key"**. - -**Why.** reCAPTCHA validates the hostname of the page the widget is rendered -in, against the domain list on the site key. The desktop client's interface -ships inside the package and is served from `app://meshbay` (`main.js`: -`win.loadURL('app://meshbay/index.html')`). Not a preference — `file://` -breaks ES modules and IndexedDB, and the hub must never become the document -origin, which is enforced by the `will-navigate` handler. So the hostname -Google sees is `meshbay`, it is not on the key's list, and it never can be: -the check happens on Google's servers and no client-side configuration reaches -it. Widening the CSP does not help, because the CSP was never what refused. - -**What is done instead.** Turn *off* "Verify the origin of reCAPTCHA -solutions" on the key, and check the origin on the hub, where it belongs: - -```toml -[captcha] -site_key = "6Le..." -secret_key = "6Le..." -allowed_hosts = ["meshbay.org", "localhost"] -# Only with the desktop client. See below — this is the loose one. -allow_unattributed_host = true -``` - -`verify_captcha` then refuses a solve whose reported hostname is not in that -list. The hostname comes from `siteverify` — it is what Google *observed*, not -something the caller asserts — so this is a real check and not a formality: the -site key is public, and the thing turning the origin check off opens is a bot -rendering the widget on a page of its own, which this refuses on the hostname -it actually served from. - -Empty (the default) means "do not check", so a hub that never touched this -setting keeps the behaviour it has, with reCAPTCHA doing the origin check -itself. **The two settings go together**: turning the console check off without -setting `allowed_hosts` leaves no origin check anywhere. - -### The hostname a desktop solve reports is empty, not `meshbay` - -Built first as an allowlist entry, on the assumption that Google would report -the host component of the origin. It does not, and registration from the -client failed with `captcha_failed` while the checkbox was green — a worse -symptom than the one being fixed, because the widget now looked fine. The log -line said it outright: - -``` -captcha solved on an unexpected host ''; allowed: ['localhost', 'meshbay', 'meshbay.org'] -``` - -A solve Google cannot attribute to a domain reports an **empty** hostname. No -allowlist entry can match that, and an empty entry is not the answer either: -a blank in a TOML list is a typo far more often than an intention, and -`load_config` drops blanks for that reason. `allow_unattributed_host` is a -named flag instead, so the trade is stated where it is made. - -**What it admits, plainly.** 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 client's own origin would have been (`main.js` already records -that `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, in something that -can render it. What is given up is the origin restriction for non-web clients, -not the captcha. A hub that does not ship the desktop client should leave the -flag off. - -**Reading the value yourself.** Any refusal is logged at WARNING, with the -hostname spelled out and the allowed list beside it when there is one. That is -how the empty hostname was found, and it is the way to check what a given -client actually reports rather than guess — which is what went wrong here. - ---- - -## 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`). - For a deployment with the desktop client, also turn off "Verify the origin - of reCAPTCHA solutions" on the key — §6. -2. Add to `/etc/meshbay/hub.toml` on the production server: - ```toml - [captcha] - site_key = "6Le..." - secret_key = "6Le..." - # Required whenever the console's origin check is off, and only then. - allowed_hosts = ["meshbay.org", "localhost"] - # Only with the desktop client — §6 says what it gives up. - allow_unattributed_host = true - ``` -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. -6. Verify the desktop client by actually registering from it. Two distinct - failures, and the first hides the second: "Invalid domain for site key" - inside the widget means the console's origin check is still on, while a - green checkbox followed by `captcha_failed` means the hub refused it — the - WARNING in the journal says which host, or that there was none. |