aboutsummaryrefslogtreecommitdiffstats
path: root/docs/captcha.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-11 00:19:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-11 00:19:06 +0200
commitf059cb118c556d1f0279350507f74b8a47d5a98a (patch)
tree9a97762a844038a06134b4b7dcead1758477dfc1 /docs/captcha.md
parentb045ba0010d69360b6a0265eb7c73a07900fe328 (diff)
downloadmeshbay-f059cb118c556d1f0279350507f74b8a47d5a98a.tar.gz
docs: remove the documents MESHBAY_DESIGN.md replaces
Twenty-four files, about 17 000 lines: the two architecture drafts, the three security reviews, eleven design notes, the roadmap, the decisions file, the v1–v4 archive, the deprecated user guide and the stale quickstart. Their content is in MESHBAY_DESIGN.md, and git history holds the originals. The reason to delete rather than keep bannered: a document that is superseded but present still gets read, and a reader cannot always tell which of two accounts of one mechanism is the live one. That was the argument for retiring the user guide rather than repairing it, and it applies to the whole set. What made this safe is the concordance. Roughly 290 comments and docstrings cite these files by section — `musicbay.md §6`, `mediacenter.md §5.5`, `draft-v6 §2.11` — and section 16 maps every one onto its replacement, so not a single comment needs editing to stay followable. It now says plainly that the files are gone and where to recover them, and it gained rows for the three reviews (their findings are section 13), and for the two guides. Four kept documents pointed into the set and were repointed first: `playlists.md` (nine references — it is a live proposal and must not dangle), `WINDOWS-PORT.md`, and CLAUDE.md's example. No dangling reference remains outside section 16. Two files were dropped from the list after checking what they hold. `HTTPS.md` is an operational runbook — Caddy, certificate renewal, DNS, troubleshooting — and MESHBAY_DESIGN.md deliberately covers no operations, so nothing would replace it; the versioned Caddyfile is the config, not the procedure. `cast-smart-tv.md` is the plan for the unbuilt DLNA phase of a feature whose first two phases ship, and section 11.4 summarises it in four lines rather than carrying the SSDP/UPnP work. There is no user guide now, and section 0.1 says so rather than leaving a reader to discover it. Suites green: 2258 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
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.