diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-14 13:34:47 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-14 13:34:47 +0200 |
| commit | 51705056a578d2649a989bf14a056fbcfae58a6b (patch) | |
| tree | dfd078aba518427387fca29b9d43c3723b030008 /packages/meshbay-hub/src | |
| parent | daec4bc6a2a9f9e038e5269586b06018ff327a27 (diff) | |
| download | meshbay-51705056a578d2649a989bf14a056fbcfae58a6b.tar.gz | |
feat(hub): replace reCAPTCHA with a self-hosted proof of work
Sign-up and passphrase reset sent every visitor's address and browser
details to Google, which needed a consent the sign-up form could not ask
for freely. The hub now issues its own challenge (ALTCHA's scheme, signed
with a key derived from the hub's private key, single-use, 30 minutes)
and the page solves it with SubtleCrypto while the form is filled in.
- GET /v1/users/captcha issues a challenge; 404 captcha_disabled when off.
- [captcha] enabled / max_number replace site_key / secret_key, which are
now ignored with a warning; MESHBAY_CAPTCHA_ENABLED replaces the old
variables.
- No Google origin left in the CSP of the hub, the desktop shell or the
Android shell; captcha_site_key leaves /v1/hub/info.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
19 files changed, 233 insertions, 241 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index 0d2d39c..b47f076 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -7,19 +7,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub import __version__, hub_settings from meshbay_hub.api import federation from meshbay_hub.auth import hub_public_key_pem -from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import get_db, get_engine router = APIRouter(prefix="/v1/hub", tags=["hub"]) -_cfg: HubConfig | None = None - - -def set_config(cfg: HubConfig) -> None: - global _cfg - _cfg = cfg - - @router.get("/info") async def hub_info(db: AsyncSession = Depends(get_db)): """Versions and the instance policy a client needs before signing in.""" @@ -40,7 +31,6 @@ async def hub_info(db: AsyncSession = Depends(get_db)): # which answers 503 — this is the same answer somewhere an operator can # read it without opening the source. "federation": federation.FEDERATION_ENABLED, - "captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "", # How long a browser tab stays signed in with nobody at it. The page # measures it (static/idle.js); the hub only says how long. "browser_idle_hours": diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 130240e..fbba6d7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -100,18 +100,24 @@ def _generate_code() -> str: async def _verify_captcha_or_raise(token: str | None, request: Request) -> None: if not token: raise HTTPException(status_code=400, detail="captcha_required") - from meshbay_hub.captcha import verify_captcha - ok = await verify_captcha( - _cfg.captcha.secret_key, # type: ignore[union-attr] - token, - request.client.host if request.client else None, - _cfg.captcha.host_check, # type: ignore[union-attr] - _cfg.captcha.allow_unattributed_host, # type: ignore[union-attr] - ) - if not ok: + from meshbay_hub.captcha import verify + if not verify(token): raise HTTPException(status_code=400, detail="captcha_failed") +@router.get("/captcha") +@limiter.limit("30/minute") +async def captcha_challenge(request: Request): + """A proof-of-work challenge for the register and reset-request forms. + + 404 when this hub has the check turned off, so the page sends no answer. + """ + if not (_cfg and _cfg.captcha.enabled): + raise HTTPException(status_code=404, detail="captcha_disabled") + from meshbay_hub.captcha import issue + return issue(_cfg.captcha.max_number) + + # ── Models ──────────────────────────────────────────────────────────────────── # Mirrored by USERNAME_MIN_LEN in static/auth-page.js; test_username_floor.py diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index badbdda..adbd522 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -82,21 +82,19 @@ _NO_STORE = {"Cache-Control": "no-store"} # `<script>`. `'wasm-unsafe-eval'` is required for the Argon2id WASM. The hub's # own origin is deliberately absent from `script-src`: a response it returns is # never executed, which is the point of T3. -_RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com" CSP = "; ".join([ "default-src 'none'", - f"script-src 'self' 'wasm-unsafe-eval' {_RECAPTCHA_SRC}", + "script-src 'self' 'wasm-unsafe-eval'", "style-src 'self' 'unsafe-inline'", - f"img-src 'self' data: blob: {_RECAPTCHA_SRC}", + "img-src 'self' data: blob:", "media-src 'self' blob:", "font-src 'self'", # The page talks to its own origin and nothing else — the hub, the node - # arrives over WebRTC, which this directive does not govern. reCAPTCHA's - # two origins are the only others, kept so its script may call home from - # this page; the register and reset views were driven in Chrome and Firefox - # against Google's test keys with this line and reported no violation. - # `https: wss:` let an injected script post anything anywhere. - f"connect-src 'self' {_RECAPTCHA_SRC}", + # arrives over WebRTC, which this directive does not govern. No third + # party has an origin here: the sign-up check is a proof of work the hub + # issues itself (captcha.py). `https: wss:` let an injected script post + # anything anywhere. + "connect-src 'self'", "worker-src 'self'", # `object-src` exists for one thing, and `frame-src`'s `blob:` for the same # thing: previewing a PDF without writing it anywhere. @@ -123,9 +121,10 @@ CSP = "; ".join([ # response it is holding. Without it Chrome refuses the frame, the worker is # never asked, and the page waits out its timeout for a download that cannot # happen — on Firefox and Safari that is the *only* way to write a large - # file to disk, so the whole path was dead. Added when reCAPTCHA needed a - # frame, which is why nobody connected the two. - f"frame-src 'self' blob: {_RECAPTCHA_SRC}", + # file to disk, so the whole path was dead. It was missing because the + # directive had been written for a third-party captcha frame, and nobody + # connected the two. + "frame-src 'self' blob:", # `'self'`, not `'none'`, and the difference is one same-origin iframe. # # The threat frame-ancestors answers is clickjacking: a *foreign* page diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 2ad71bd..6155470 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -23,7 +23,6 @@ from meshbay_hub.api.federation import router as federation_router from meshbay_hub.api.groups import router as groups_router from meshbay_hub.api.health import router as health_router from meshbay_hub.api.hub import router as hub_router -from meshbay_hub.api.hub import set_config as hub_set_config from meshbay_hub.api.invite_links import redeem_router as invite_links_redeem_router from meshbay_hub.api.invite_links import router as invite_links_router from meshbay_hub.api.middleware import limiter @@ -140,7 +139,6 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: generate_hub_keypair(kp) load_hub_keypair(kp, cfg.identity.id) users_set_config(cfg) - hub_set_config(cfg) set_admin_usernames(cfg.identity.admin_usernames) await _backfill_email_hashes() diff --git a/packages/meshbay-hub/src/meshbay_hub/captcha.py b/packages/meshbay-hub/src/meshbay_hub/captcha.py index cf33d52..e6faf1a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/captcha.py +++ b/packages/meshbay-hub/src/meshbay_hub/captcha.py @@ -1,85 +1,86 @@ -"""reCAPTCHA v2 server-side verification.""" +"""Proof-of-work challenge gating registration and password reset. -import logging +The hub hands out a challenge; the page finds the number behind it by trying +them in turn and sends the answer with the form. Nobody else is asked: no +script, cookie or address leaves for a third party, which is what the +reCAPTCHA this replaced could not say (docs/MESHBAY_DESIGN.md §7.7). -import httpx +What it buys is cost, not a verdict on who is human. A person never sees it — +the page solves while the form is being filled in — and a script pays it once +per attempt. The bounds that hold against a script with machines to spare are +the per-IP limits on the two routes and, above all, the mail budget +(`mail.py`): the sign-up and the reset are what make this hub send mail. -log = logging.getLogger(__name__) +The scheme is ALTCHA's, keyed to this hub. A challenge is the SHA-256 of +`<nonce>.<expires>.<number>`, with `number` drawn below `max_number`; its HMAC +is the hub's word that it issued it. The answer carries the nonce, the expiry, +the number and that HMAC, so the hub recomputes the challenge rather than +remembering it — and every worker, and the next restart, agrees on it, since +the key is derived from the hub's own private key and not drawn per process. +What is remembered is an answer once spent, so one solve is one form. +""" -VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" +import hashlib +import hmac +import secrets +import time +from meshbay_hub.auth import hub_private_key_pem -async def verify_captcha( - secret_key: str, - token: str, - remote_ip: str | None = None, - allowed_hosts: frozenset[str] | set[str] | None = None, - allow_unattributed: bool = False, -) -> bool: - """True when Google accepts the token, and it came from a host we expect. +# How long a challenge stays good. Long enough for somebody filling in the form +# slowly; the page fetches a fresh one if it outlived this before submitting. +CHALLENGE_TTL = 1800 - `allowed_hosts` is the answer to a reCAPTCHA key whose own origin check is - turned off. That check compares the *page's hostname* against the key's - domain list, and the desktop client's page is served from `app://meshbay` - — Chromium refuses a service worker on a custom scheme and the interface - ships inside the package, so it cannot be `https://<hub>` and the hostname - cannot be the hub's. Google answers "Invalid domain for site key" inside - its own widget, which is not something the client can configure away. +_KEY_LABEL = b"meshbay captcha v1" +# Answers already accepted, until their challenge would have expired anyway. +_spent: dict[str, int] = {} - Turning the key's origin check off moves that decision here, where the hub - knows which hosts are its own. `siteverify` reports the hostname it - observed, not one the caller asserts, so this is a real check and not a - formality — a bot embedding the site key on a page of its own is refused - on the hostname it actually served from. - `None` (the default) skips it, which is what a deployment leaving the - origin check with Google wants: it is then already done, one layer up. +def _key() -> bytes: + return hmac.new(hub_private_key_pem(), _KEY_LABEL, hashlib.sha256).digest() - `allow_unattributed` covers the case that made the desktop client fail - anyway. A solve from a page Google cannot attribute to a domain comes back - with an **empty** hostname, not the host part of the origin — measured - live: `app://meshbay` reports `''`, never `'meshbay'`. So an allowlist - entry can never match it, and an empty string cannot be an allowlist entry - either: a blank in a TOML list is a typo far more often than it is an - intention, and the config parser drops blanks for that reason. - What it admits is 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 desktop client's own origin would have been (`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. What is given up is the origin - restriction for non-web clients, not the captcha. +def _sign(challenge: str) -> str: + return hmac.new(_key(), challenge.encode(), hashlib.sha256).hexdigest() + + +def issue(max_number: int) -> dict: + """A fresh challenge, in the shape the page solves (`static/captcha.js`).""" + prefix = f"{secrets.token_hex(12)}.{int(time.time()) + CHALLENGE_TTL}." + number = secrets.randbelow(max_number) + challenge = hashlib.sha256(f"{prefix}{number}".encode()).hexdigest() + return { + "prefix": prefix, + "challenge": challenge, + "max_number": max_number, + "signature": _sign(challenge), + "expires_in": CHALLENGE_TTL, + } + + +def verify(token: str) -> bool: + """True for an answer this hub issued, still in date and not spent before. + + The token is `<nonce>.<expires>.<number>.<signature>`. Nothing in it is + trusted on its own: the challenge is recomputed from the first three, and + only this hub's key turns that into the signature. """ - payload: dict[str, str] = {"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 False - if allowed_hosts is not None: - # Logged at warning with the hostname spelled out: this is also - # how an operator finds what to allow after adding a client - # whose origin they have not seen before. It is how the empty - # one was found. - host = result.get("hostname") or "" - if not host: - if not allow_unattributed: - log.warning( - "captcha solved on a host Google did not attribute; " - "set captcha.allow_unattributed_host to admit " - "non-web clients such as the desktop application") - return False - elif host not in allowed_hosts: - log.warning( - "captcha solved on an unexpected host %r; allowed: %s", - host, sorted(allowed_hosts)) - return False - return True - except Exception: - log.exception("captcha verification request failed") + parts = token.split(".") + if len(parts) != 4: + return False + nonce, expires_s, number_s, signature = parts + if not (expires_s.isdigit() and number_s.isdigit()): + return False + now = int(time.time()) + if not now < int(expires_s) <= now + CHALLENGE_TTL: + return False + challenge = hashlib.sha256(f"{nonce}.{expires_s}.{number_s}".encode()).hexdigest() + if not hmac.compare_digest(_sign(challenge), signature): + return False + for spent, until in list(_spent.items()): + if until <= now: + del _spent[spent] + if signature in _spent: return False + _spent[signature] = int(expires_s) + return True diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py index e618510..f710ead 100644 --- a/packages/meshbay-hub/src/meshbay_hub/config.py +++ b/packages/meshbay-hub/src/meshbay_hub/config.py @@ -66,33 +66,15 @@ class JWTConfig: @dataclass class CaptchaConfig: - site_key: str = "" - secret_key: str = "" - # Hostnames a solved captcha may have come from, checked against the one - # `siteverify` reports. Empty means "do not check", which is right while - # the reCAPTCHA key does its own origin check — it is then already done. - # - # Set this when that check is turned off in the reCAPTCHA console, which is - # what the desktop client needs: its page is served from `app://meshbay`, - # so the hostname Google sees is not the hub's and never can be. See - # docs/MESHBAY_DESIGN.md §7.7. - allowed_hosts: list[str] = field(default_factory=list) - # A solve from a page Google cannot attribute to a domain reports an empty - # hostname — `app://meshbay` does, measured live, and so does any other - # non-web client. No `allowed_hosts` entry can match that, and a blank - # entry is not the answer: the parser drops blanks because in a TOML list - # a blank is a typo far more often than an intention. Hence a named flag, - # which also states the trade at the place it is made. - allow_unattributed_host: bool = False - - @property - def enabled(self) -> bool: - return bool(self.site_key and self.secret_key) - - @property - def host_check(self) -> frozenset[str] | None: - """The set to hand `verify_captcha`, or None for "do not check".""" - return frozenset(self.allowed_hosts) if self.allowed_hosts else None + # The proof of work on registration and password reset (captcha.py). On + # unless a configuration turns it off, which is for development and for a + # hub nobody can reach. + enabled: bool = True + # The work asked of the page: it tries numbers from 0 until one matches, so + # half of this on average and all of it at worst. Sized for the slowest + # phone that signs up — about three seconds there, solved while the form is + # being filled in — not for the desktop, which takes a fraction of that. + max_number: int = 500_000 @dataclass @@ -183,12 +165,12 @@ def load_config(path: Path | None = None) -> HubConfig: ): setattr(cfg.mail, name, ml.get(name, getattr(cfg.mail, name))) 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) - if hosts := cap.get("allowed_hosts"): - cfg.captcha.allowed_hosts = [str(h).strip() for h in hosts if str(h).strip()] - if "allow_unattributed_host" in cap: - cfg.captcha.allow_unattributed_host = bool(cap["allow_unattributed_host"]) + cfg.captcha.enabled = bool(cap.get("enabled", cfg.captcha.enabled)) + cfg.captcha.max_number = max(1, int(cap.get("max_number", cfg.captcha.max_number))) + if "site_key" in cap or "secret_key" in cap: + log.warning("captcha.site_key and captcha.secret_key are no longer " + "read: registration is gated by a proof of work, with " + "no third party; remove them from the configuration") break # Env var overrides @@ -204,13 +186,7 @@ def load_config(path: Path | None = None) -> HubConfig: cfg.identity.private_key_path = Path(kp).expanduser() if admin_users := os.environ.get("MESHBAY_ADMIN_USERS"): cfg.identity.admin_usernames = [u.strip() for u in admin_users.split(",") if u.strip()] - if captcha_site := os.environ.get("MESHBAY_CAPTCHA_SITE_KEY"): - cfg.captcha.site_key = captcha_site - if captcha_secret := os.environ.get("MESHBAY_CAPTCHA_SECRET_KEY"): - cfg.captcha.secret_key = captcha_secret - if captcha_hosts := os.environ.get("MESHBAY_CAPTCHA_ALLOWED_HOSTS"): - cfg.captcha.allowed_hosts = [h.strip() for h in captcha_hosts.split(",") if h.strip()] - if unattributed := os.environ.get("MESHBAY_CAPTCHA_ALLOW_UNATTRIBUTED_HOST"): - cfg.captcha.allow_unattributed_host = unattributed.lower() in ("1", "true", "yes") + if captcha := os.environ.get("MESHBAY_CAPTCHA_ENABLED"): + cfg.captcha.enabled = captcha.lower() in ("1", "true", "yes") return cfg diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index e014f61..c5374f8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -8,6 +8,7 @@ import { import * as platform from './platform.js'; import { Icon } from './icon.js'; import { loadPending } from './invite-link.js'; +import { solve } from './captcha.js'; const PASSWORD_MIN_BITS = 60; const PASSWORD_MIN_LEN = 12; @@ -44,73 +45,63 @@ function PasswordInput({ `; } -// ── reCAPTCHA v2 helper ────────────────────────────────────────────────────── - -let _captchaSiteKey = null; -let _captchaKeyFetched = false; - -async function fetchCaptchaSiteKey() { - if (_captchaKeyFetched) return _captchaSiteKey; - try { - const info = await hubFetch('/v1/hub/info'); - _captchaSiteKey = info.captcha_site_key || null; - } catch { _captchaSiteKey = null; } - _captchaKeyFetched = true; - return _captchaSiteKey; -} - -function loadRecaptchaScript() { - 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); -} +// ── Proof of work (captcha.js) ─────────────────────────────────────────────── +// +// Fetched and solved as soon as the form is on screen, so by the time somebody +// has typed a name, an address and a passphrase twice there is nothing left to +// wait for. Nothing is drawn: there is nothing for a person to do. A hub with +// the check turned off answers 404, and the form then sends no answer at all. function useCaptcha() { - const [siteKey, setSiteKey] = useState(_captchaSiteKey); - const [token, setToken] = useState(null); - const containerRef = useRef(null); - const widgetId = useRef(null); + const pending = useRef(null); + const abort = useRef(null); - useEffect(() => { - fetchCaptchaSiteKey().then(k => { - if (k) { setSiteKey(k); loadRecaptchaScript(); } - }); + const start = useCallback(() => { + if (abort.current) abort.current.abort(); + const ctl = new AbortController(); + abort.current = ctl; + pending.current = (async () => { + let challenge; + try { + challenge = await hubFetch('/v1/users/captcha'); + } catch (err) { + return err.message === 'captcha_disabled' ? { token: null, until: Infinity } : null; + } + // A minute short of the hub's own limit, so an answer is never sent + // that expires on its way there. + const until = Date.now() + (challenge.expires_in - 60) * 1000; + return { token: await solve(challenge, ctl.signal), until }; + })(); }, []); useEffect(() => { - if (!siteKey || !containerRef.current) return; - const poll = setInterval(() => { - if (window.grecaptcha && window.grecaptcha.render && widgetId.current === null) { - clearInterval(poll); - widgetId.current = window.grecaptcha.render(containerRef.current, { - sitekey: siteKey, - callback: (tk) => setToken(tk), - 'expired-callback': () => setToken(null), - theme: document.documentElement.getAttribute('data-theme') === 'dark' - ? 'dark' : 'light', - }); - } - }, 100); - return () => clearInterval(poll); - }, [siteKey]); + start(); + return () => abort.current && abort.current.abort(); + }, [start]); - const reset = useCallback(() => { - if (widgetId.current !== null && window.grecaptcha) { - window.grecaptcha.reset(widgetId.current); - setToken(null); + // The answer for this submission — waited for if it is still being worked + // out, and worked out again if the form sat open past its expiry or the + // first fetch failed. Null only when the hub has no check, or cannot be + // reached, in which case the hub's own refusal is what the person sees. + const take = useCallback(async () => { + let got = await pending.current; + if (!got || got.until < Date.now()) { + start(); + got = await pending.current; } - }, []); + return got ? got.token : null; + }, [start]); - const widget = siteKey - ? html`<div ref=${containerRef} - style="display:flex;justify-content:center;margin:12px 0"></div>` - : null; + // An answer is spent once the hub has checked it, whatever happened after — + // a name already taken is refused past that point. Call after a failure. + return { take, renew: start }; +} - return { token, widget, reset, enabled: !!siteKey }; +// The hub's refusal of the proof of work, which a person can do nothing about +// but try again, in words rather than as the code it is sent as. +function errorText(err) { + return err.message === 'captcha_required' || err.message === 'captcha_failed' + ? t('captcha.failed') : err.message; } function passwordBits(pw) { @@ -411,11 +402,9 @@ export function RegisterPage() { const rk = window.MeshBayKeys.generateRecoveryKey(); // `name` (trimmed), not the raw field: the hub stores the trimmed // username and every key derivation must fold in the same string. - // `captcha.token` rides along — the submit button is already disabled - // until it is set when a captcha is configured (see the form below). const reg = await window.MeshBayKeys.registerUser( name, email, password, emailRecovery ? rk.mnemonic : null, - captcha.token); + await captcha.take()); // An account made in the desktop application starts without browser // access: its identities stay on this device and nothing of them is // left on nodes until the person turns it on, here, natively. @@ -433,17 +422,14 @@ export function RegisterPage() { body: { username: name, email, password, pk_user_ed25519: '', pk_user_x25519: '', - captcha_token: captcha.token, + captcha_token: await captcha.take(), }, }); setPhase('verify'); } } catch (err) { - setError(err.message); - // A reCAPTCHA token is single-use: after a failed attempt (name taken, - // e-mail in use…) it is spent, so clear it and make the user solve a - // fresh one before the next try. No-op when no captcha is configured. - captcha.reset(); + setError(errorText(err)); + captcha.renew(); } finally { setLoading(false); } @@ -609,9 +595,8 @@ export function RegisterPage() { onChange=${e => setEmailRecovery(e.target.checked)} /> <span>${t('register.recovery_email_opt')}</span> </label> - ${captcha.widget} ${error && html`<div class="error-msg">${error}</div>`} - <button type="submit" disabled=${loading || (captcha.enabled && !captcha.token)}> + <button type="submit" disabled=${loading}> ${loading ? t('register.loading') : t('register.submit')} </button> <p class="register-legal"> @@ -658,12 +643,13 @@ export function ResetPasswordPage({ onLogin }) { method: 'POST', body: { username: username.trim(), email: email.trim(), - captcha_token: captcha.token, + captcha_token: await captcha.take(), }, }); setPhase('form'); } catch (err) { - setError(err.message); + setError(errorText(err)); + captcha.renew(); } finally { setBusy(false); } @@ -771,9 +757,8 @@ export function ResetPasswordPage({ onLogin }) { <input type="email" placeholder="${t('register.email')}" value=${email} onInput=${e => setEmail(e.target.value)} autocomplete="email" required /> - ${captcha.widget} ${error && html`<div class="error-msg">${error}</div>`} - <button type="submit" disabled=${busy || (captcha.enabled && !captcha.token)}> + <button type="submit" disabled=${busy}> ${t('reset.send_code')}</button> </form>`} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/captcha.js b/packages/meshbay-hub/src/meshbay_hub/static/captcha.js new file mode 100644 index 0000000..bc174a0 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/captcha.js @@ -0,0 +1,47 @@ +// The proof of work that gates sign-up and passphrase reset (the hub's +// captcha.py). The hub sends the SHA-256 of `<prefix><number>` and the page +// finds the number by trying them from 0, so a person waits for nothing: it is +// solved while the form is being filled in. Nothing leaves for a third party. +// +// On the main thread, in batches handed to SubtleCrypto, rather than in a Web +// Worker: the Android shell serves the interface through its own request +// interceptor, and a worker's script is a request that interceptor is not +// guaranteed to see. Each batch is a turn of the event loop, so typing in the +// form is not held up by it. + +const BATCH = 1000; + +function fromHex(hex) { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); + return out; +} + +function same(buf, want) { + const got = new Uint8Array(buf); + for (let i = 0; i < want.length; i++) if (got[i] !== want[i]) return false; + return true; +} + +/** + * The answer to a challenge from `GET /v1/users/captcha`, as the token the + * register and reset-request bodies carry — or null if no number below + * `max_number` matches, which only a challenge that is not the hub's produces, + * or once `signal` is aborted: the form was left before it was needed. + */ +export async function solve({ prefix, challenge, max_number: max, signature }, signal) { + const enc = new TextEncoder(); + const want = fromHex(challenge); + for (let start = 0; start < max; start += BATCH) { + if (signal && signal.aborted) return null; + const end = Math.min(start + BATCH, max); + const pending = []; + for (let n = start; n < end; n++) { + pending.push(crypto.subtle.digest('SHA-256', enc.encode(prefix + n))); + } + const digests = await Promise.all(pending); + const hit = digests.findIndex(d => same(d, want)); + if (hit >= 0) return `${prefix}${start + hit}.${signature}`; + } + return null; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index edf4df9..bd5282c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -401,9 +401,9 @@ async function registerUser(username, email, password, recoveryMnemonic, captcha // appends it to the verification e-mail and stores it nowhere // (docs/MESHBAY_DESIGN.md §3.6). Omitted when they chose to save it themselves. if (recoveryMnemonic) payload.recovery_key = recoveryMnemonic; - // reCAPTCHA response, when the hub has a captcha configured. The widget lives - // in RegisterPage (auth-page.js); this function just forwards its token. A - // hub with no captcha configured sends nothing and the server does not check. + // The proof-of-work answer, when the hub asks for one. RegisterPage + // (auth-page.js) works it out; this function just forwards it. A hub with + // the check turned off gets nothing and does not look. if (captchaToken) payload.captcha_token = captchaToken; const resp = await hubCall('/v1/users/register', { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 1fe2b36..bf16bca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -139,8 +139,7 @@ export default { 'register.strength': 'Stärke: etwa {bits} Bit. Sie schützt die Kopie Ihrer ' + 'Schlüssel, die auf den Nodes liegt, denen Sie beitreten — es lohnt sich also, ' + 'sie gut zu wählen.', - 'captcha.required': 'Bitte das Captcha ausfüllen.', - 'captcha.failed': 'Captcha-Überprüfung fehlgeschlagen — bitte erneut versuchen.', + 'captcha.failed': "Die Prüfung gegen Roboter ist fehlgeschlagen. Bitte versuchen Sie es erneut.", // Home 'home.welcome': 'Willkommen bei MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 642d3e2..c7dc47e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -140,8 +140,7 @@ export default { + 'your keys where they are stored — a few unrelated words work well.', 'register.strength': 'Strength: about {bits} bits. This protects the copy of ' + 'your keys kept on the nodes you join, so it is worth getting right.', - 'captcha.required': 'Please complete the captcha.', - 'captcha.failed': 'Captcha verification failed — please try again.', + 'captcha.failed': "The anti-robot check failed. Please try again.", // Home 'home.welcome': 'Welcome to MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index fe66af4..0831bac 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -137,8 +137,7 @@ export default { + 'relación entre sí funcionan muy bien.', 'register.strength': 'Solidez: unos {bits} bits. Protege la copia de sus claves que ' + 'se guarda en los nodes a los que se une, así que conviene acertar.', - 'captcha.required': 'Por favor, complete el captcha.', - 'captcha.failed': 'Verificación del captcha fallida — inténtelo de nuevo.', + 'captcha.failed': "La verificación anti-robots ha fallado. Inténtelo de nuevo.", // Home 'home.welcome': 'Bienvenido a MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 6296a8b..2a52848 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -138,8 +138,7 @@ export default { 'register.strength': 'Robustesse : environ {bits} bits. Elle protège la copie de ' + 'vos clés conservée sur les nodes que vous rejoignez, cela vaut donc la peine ' + 'de la soigner.', - 'captcha.required': 'Veuillez compléter le captcha.', - 'captcha.failed': 'Vérification du captcha échouée — veuillez réessayer.', + 'captcha.failed': "La vérification anti-robot a échoué. Veuillez réessayer.", // Home 'home.welcome': 'Bienvenue sur MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 0751692..1dd3f52 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -138,8 +138,7 @@ export default { + 'legame tra loro funziona bene.', 'register.strength': 'Robustezza: circa {bits} bit. Protegge la copia delle sue ' + 'chiavi conservata sui node a cui aderisce, quindi vale la pena sceglierla con cura.', - 'captcha.required': 'Completa il captcha.', - 'captcha.failed': 'Verifica captcha fallita — riprova.', + 'captcha.failed': "La verifica anti-robot non è riuscita. Riprovi.", // Home 'home.welcome': 'Benvenuto in MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index c3d4fa8..0d4aadf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -136,8 +136,7 @@ export default { + 'その鍵を守るものです。互いに関係のない単語をいくつか並べると効果的です。', 'register.strength': '強度:約 {bits} ビット。参加している各 node に保管される鍵の' + 'コピーを守るものですので、しっかり決めておく価値があります。', - 'captcha.required': 'キャプチャを完了してください。', - 'captcha.failed': 'キャプチャの検証に失敗しました — もう一度お試しください。', + 'captcha.failed': "ロボット対策の確認に失敗しました。もう一度お試しください。", // Home 'home.welcome': 'MeshBay へようこそ', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index c02be99..31fc7b3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -139,8 +139,7 @@ export default { 'register.strength': 'Sterkte: ongeveer {bits} bits. Ze beschermt de kopie van uw ' + 'sleutels die op de nodes staat waar u zich bij aansluit, dus het loont om er ' + 'even bij stil te staan.', - 'captcha.required': 'Vul de captcha in.', - 'captcha.failed': 'Captchaverificatie mislukt — probeer het opnieuw.', + 'captcha.failed': "De controle tegen robots is mislukt. Probeer het opnieuw.", // Home 'home.welcome': 'Welkom bij MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 97e8b53..e88272d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -143,8 +143,7 @@ export default { + 'bardzo dobrze.', 'register.strength': 'Siła: około {bits} bitów. Chroni kopię kluczy przechowywaną na ' + 'nodes, do których się dołącza, więc warto ją dobrać starannie.', - 'captcha.required': 'Proszę wypełnić captcha.', - 'captcha.failed': 'Weryfikacja captcha nie powiodła się — spróbuj ponownie.', + 'captcha.failed': "Weryfikacja antyrobotowa nie powiodła się. Spróbuj ponownie.", // Home 'home.welcome': 'Witamy w MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 8ff36a5..5b2d80f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -139,8 +139,7 @@ export default { + 'entre si funcionam muito bem.', 'register.strength': 'Robustez: cerca de {bits} bits. Ela protege a cópia das suas ' + 'chaves mantida nos nodes dos quais você participa, então vale a pena caprichar.', - 'captcha.required': 'Por favor, complete o captcha.', - 'captcha.failed': 'Verificação do captcha falhou — tente novamente.', + 'captcha.failed': "A verificação anti-robô falhou. Tente novamente.", // Home 'home.welcome': 'Bem-vindo ao MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index d4ad64f..a6ea535 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -136,8 +136,7 @@ export default { + '几个互不相关的词就很好用。', 'register.strength': '强度:约 {bits} 位。它保护的是您所加入的各个 node 上保存的密钥副本,' + '因此值得认真设置。', - 'captcha.required': '请完成验证码。', - 'captcha.failed': '验证码验证失败——请重试。', + 'captcha.failed': "防机器人验证失败,请重试。", // Home 'home.welcome': '欢迎使用 MeshBay', |