aboutsummaryrefslogtreecommitdiffstats
path: root/docs/captcha.md
blob: 70138046b788b5ab524430816b203cc38dc0f542 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
# 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.