aboutsummaryrefslogtreecommitdiffstats
path: root/docs/captcha.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/captcha.md')
-rw-r--r--docs/captcha.md555
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.