aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt7
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt14
-rw-r--r--packages/meshbay-client/src/main.js23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/captcha.py145
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py58
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js129
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/captcha.js47
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js3
-rw-r--r--packages/meshbay-hub/tests/conftest.py4
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py3
-rw-r--r--packages/meshbay-hub/tests/test_captcha_host_check.py252
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py34
-rw-r--r--packages/meshbay-hub/tests/test_register_captcha.py148
-rw-r--r--packages/meshbay-hub/tests/test_security_headers.py12
-rw-r--r--packages/meshbay-hub/tests/test_unauthenticated_surface.py3
29 files changed, 394 insertions, 580 deletions
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
index 699e0c3..6bd2a3f 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
@@ -198,9 +198,8 @@ class MainActivity : Activity() {
return docs.firstNotNullOfOrNull { it.serve(path) } ?: refused()
}
if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused()
- // reCAPTCHA (sign-up) and nothing else goes to the network from
- // the page; the policy says the same, this is the second wall.
- if (UiAssets.isRecaptcha(url.host) && url.scheme == "https") return null
+ // Nothing goes to the network from the page; the policy says
+ // the same, this is the second wall.
if (url.scheme == "blob" || url.scheme == "data") return null
return refused()
}
@@ -211,7 +210,7 @@ class MainActivity : Activity() {
// The hub must never become the document origin. A link out
// opens in the person's browser, not in a window holding keys.
if (request.isForMainFrame && (url.scheme == "https" || url.scheme == "http")) openExternally(url)
- return !(UiAssets.isRecaptcha(url.host) && !request.isForMainFrame)
+ return true
}
}
web.webChromeClient = object : WebChromeClient() {
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt
index 2300668..0721db3 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt
@@ -45,10 +45,6 @@ class UiAssets(private val context: Context) : WebViewAssetLoader.PathHandler {
const val PREFIX = "/ui/"
const val START = "$ORIGIN${PREFIX}index.html"
- // reCAPTCHA gates sign-up here as it does in a browser and on the
- // desktop; these two hosts and no others.
- private const val RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"
-
/**
* meshbay-client/src/main.js's CSP, directive for directive
* (test_android_shell.py holds them together). `'wasm-unsafe-eval'` is
@@ -56,22 +52,20 @@ class UiAssets(private val context: Context) : WebViewAssetLoader.PathHandler {
*/
val CSP = listOf(
"default-src 'none'",
- "script-src 'self' 'wasm-unsafe-eval' $RECAPTCHA_SRC",
+ "script-src 'self' 'wasm-unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
- "img-src 'self' data: blob: $RECAPTCHA_SRC",
+ "img-src 'self' data: blob:",
"media-src 'self' blob:",
"font-src 'self'",
- "connect-src 'self' $RECAPTCHA_SRC",
+ "connect-src 'self'",
"worker-src 'self'",
"object-src blob:",
- "frame-src blob: $RECAPTCHA_SRC",
+ "frame-src blob:",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'none'",
).joinToString("; ")
- fun isRecaptcha(host: String?) = host == "www.google.com" || host == "www.gstatic.com"
-
fun contentType(name: String): String = when (File(name).extension.lowercase()) {
"html" -> "text/html"
"js", "mjs" -> "text/javascript"
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 33388e8..8845b7b 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -115,29 +115,16 @@ function nodeLogHint() {
// The hub is reachable under connect-src, for its API and its signaling socket.
// It is deliberately absent from script-src: nothing it returns is executed,
// which is the whole reason this application exists (T3).
-//
-// The one exception is reCAPTCHA, used to gate sign-up (and password reset) the
-// same way it gates them in the browser. Its script comes from www.google.com,
-// its challenge is a www.google.com iframe, and its assets sit on
-// www.gstatic.com. These two hosts — and only these two — are allowed under
-// `script-src`, `frame-src` and `img-src` for that purpose. It is a real, if
-// small, dent in "no third-party code runs here": Google's reCAPTCHA script
-// executes in the renderer. It is accepted deliberately so a native sign-up is
-// gated like a web one without asking the user to do anything extra, and it is
-// the *same* dependency the hub-served SPA already carries. If sign-up ever
-// moves to a proof-of-work challenge, delete RECAPTCHA_SRC and the three
-// directives that spread it, and the widget in auth-page.js with them.
-const RECAPTCHA_SRC = 'https://www.google.com https://www.gstatic.com';
const CSP = [
"default-src 'none'",
- `script-src 'self' 'wasm-unsafe-eval' ${RECAPTCHA_SRC}`,
+ "script-src 'self' 'wasm-unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
- `img-src 'self' data: blob: ${RECAPTCHA_SRC}`,
+ "img-src 'self' data: blob:",
"media-src 'self' blob:",
"font-src 'self'",
// Every hub call leaves from this process, so the page connects to nothing
- // but its own files and reCAPTCHA; see the hub's webapp.CSP.
- `connect-src 'self' ${RECAPTCHA_SRC}`,
+ // but its own files; see the hub's webapp.CSP.
+ "connect-src 'self'",
"worker-src 'self'",
// `blob:` here and in `frame-src` are one thing, not two: the PDF preview.
//
@@ -157,7 +144,7 @@ const CSP = [
// is PDFium parsing bytes that came from a node — exactly what a browser
// does with the same file.
"object-src blob:",
- `frame-src blob: ${RECAPTCHA_SRC}`,
+ "frame-src blob:",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'none'",
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',
diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py
index bb0ed28..5104ae2 100644
--- a/packages/meshbay-hub/tests/conftest.py
+++ b/packages/meshbay-hub/tests/conftest.py
@@ -29,6 +29,7 @@ def hub_key_path(tmp_path_factory) -> Path:
@pytest.fixture
def hub_config(hub_key_path, tmp_path):
from meshbay_hub.config import (
+ CaptchaConfig,
DatabaseConfig,
HubConfig,
HubIdentityConfig,
@@ -40,6 +41,9 @@ def hub_config(hub_key_path, tmp_path):
server=ServerConfig(host="127.0.0.1", port=8000),
identity=HubIdentityConfig(id="test-hub", private_key_path=hub_key_path),
jwt=JWTConfig(access_token_ttl=3600),
+ # Off here, so every test that registers is not also solving a proof of
+ # work; test_register_captcha.py turns it on.
+ captcha=CaptchaConfig(enabled=False),
)
return cfg
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
index cd5a36e..1049a5f 100644
--- a/packages/meshbay-hub/tests/test_android_shell.py
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -71,7 +71,6 @@ def test_the_policy_is_the_desktop_policy_sent_as_a_header():
body = text.split(start, 1)[1].split(end, 1)[0]
out = {}
for d in re.findall(r"[`\"]([a-z-]+(?: [^`\"]*)?)[`\"]", body):
- d = d.replace("${RECAPTCHA_SRC}", "$RECAPTCHA_SRC")
out[d.split()[0]] = d
return out
@@ -82,8 +81,6 @@ def test_the_policy_is_the_desktop_policy_sent_as_a_header():
assets = _read(SRC / "shell" / "UiAssets.kt")
assert '"Content-Security-Policy" to CSP' in assets
- recaptcha = 'RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"'
- assert recaptcha in assets
markup = re.sub(r"<!--.*?-->", "", _read(CLIENT / "scripts" / "index.html"), flags=re.S)
assert "Content-Security-Policy" not in markup
diff --git a/packages/meshbay-hub/tests/test_captcha_host_check.py b/packages/meshbay-hub/tests/test_captcha_host_check.py
deleted file mode 100644
index a0ff509..0000000
--- a/packages/meshbay-hub/tests/test_captcha_host_check.py
+++ /dev/null
@@ -1,252 +0,0 @@
-"""A solved captcha has to have been solved on one of our own pages.
-
-reCAPTCHA normally does this itself: the widget reports the hostname of the
-page it is rendered in, and Google compares it against the key's domain list.
-That is the check the desktop client cannot pass. Its interface ships inside
-the package and is served from `app://meshbay` — not a preference, Chromium
-refuses a service worker on a custom scheme and the hub must never become the
-document origin — so the hostname Google sees is not the hub's and never can
-be. The widget renders "ERROR for site owner: Invalid domain for site key",
-which no amount of client-side configuration reaches, because the decision is
-made on Google's servers.
-
-Turning the key's own origin check off moves the decision here. `siteverify`
-then reports the hostname it *observed* — not one the caller asserts — so this
-stays a real check: a bot that embeds the site key on a page of its own is
-refused on the hostname it actually served from, which is the attack that
-turning the check off would otherwise open.
-
-`allowed_hosts` empty means "do not check", which is what a deployment that
-left the origin check with Google wants. That is the default, so an existing
-hub upgrades without its captcha changing behaviour.
-
-The desktop client needed one more thing, found only by trying it. Its origin
-is `app://meshbay`, and the guess was that Google would report the host part,
-`meshbay`. It does not: a solve it cannot attribute to a domain comes back
-with an **empty** hostname. `allow_unattributed_host` is what admits those,
-and it is a named flag rather than an allowlist entry because a blank in a
-TOML list is a typo far more often than an intention.
-"""
-
-import json
-
-import pytest
-from meshbay_hub.captcha import verify_captcha
-from meshbay_hub.config import CaptchaConfig, load_config
-
-
-class _Resp:
- def __init__(self, payload):
- self._payload = payload
-
- def raise_for_status(self):
- return None
-
- def json(self):
- return json.loads(json.dumps(self._payload))
-
-
-class _Client:
- """Stands in for httpx.AsyncClient, and records what was sent."""
-
- sent: list[dict] = []
-
- def __init__(self, payload):
- self._payload = payload
-
- def __call__(self, *a, **kw):
- return self
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, *exc):
- return False
-
- async def post(self, url, data=None):
- type(self).sent.append(dict(data or {}))
- return _Resp(self._payload)
-
-
-@pytest.fixture
-def google(monkeypatch):
- """`siteverify` answers whatever the test says it answers."""
- def _answer(**payload):
- _Client.sent = []
- monkeypatch.setattr("httpx.AsyncClient", _Client(payload))
- return _answer
-
-
-@pytest.mark.asyncio
-async def test_a_solve_from_an_expected_host_is_accepted(google):
- google(success=True, hostname="meshbay.org")
- assert await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"}))
-
-
-@pytest.mark.asyncio
-async def test_an_unattributed_solve_is_refused_by_default(google):
- """What the desktop client actually produces, and what it costs.
-
- `app://meshbay` does not report `meshbay`. Google attributes the solve to
- no domain at all and the hostname comes back **empty** — measured live,
- after the first attempt to allowlist `meshbay` failed. Refusing by default
- is right: an answer with no hostname must not read as "any hostname".
- """
- google(success=True, hostname="")
- assert not await verify_captcha(
- "s", "t", allowed_hosts=frozenset({"meshbay.org", "localhost"}))
-
-
-@pytest.mark.asyncio
-async def test_the_flag_admits_it_and_the_allowlist_is_not_how(google):
- """The flag is the only way in, on purpose.
-
- An empty allowlist entry would be the obvious alternative and is a bad
- one: a blank in a TOML list is a typo far more often than an intention,
- and `load_config` drops blanks for exactly that reason. So this must not
- work by accident from an allowlist alone.
- """
- google(success=True, hostname="")
- allowed = frozenset({"meshbay.org", "localhost"})
- assert await verify_captcha("s", "t", allowed_hosts=allowed, allow_unattributed=True)
- assert not await verify_captcha("s", "t", allowed_hosts=allowed | {""})
-
-
-@pytest.mark.asyncio
-async def test_the_flag_does_not_open_the_allowlist(google):
- """It admits *unattributed* solves, not solves from somewhere else.
-
- A bot on its own web page still reports that page's hostname, which is
- still not ours, flag or no flag.
- """
- google(success=True, hostname="a-bot-farm.example")
- assert not await verify_captcha(
- "s", "t", allowed_hosts=frozenset({"meshbay.org"}), allow_unattributed=True)
-
-
-@pytest.mark.asyncio
-async def test_a_solve_from_somewhere_else_is_refused(google):
- """The attack that turning off the key's origin check would open: the site
- key is public, so a bot can render the widget on a page of its own."""
- google(success=True, hostname="a-bot-farm.example")
- assert not await verify_captcha(
- "s", "t", allowed_hosts=frozenset({"meshbay.org", "meshbay"}))
-
-
-@pytest.mark.asyncio
-async def test_an_absent_hostname_field_reads_like_an_empty_one(google):
- """`hostname` missing and `hostname: ""` are the same answer."""
- google(success=True)
- allowed = frozenset({"meshbay.org"})
- assert not await verify_captcha("s", "t", allowed_hosts=allowed)
- assert await verify_captcha("s", "t", allowed_hosts=allowed, allow_unattributed=True)
-
-
-@pytest.mark.asyncio
-async def test_no_allowed_hosts_means_google_is_still_the_one_checking(google):
- """The default, and what every hub that never touched this config does.
-
- The hostname is whatever it is and this does not look: the key's own origin
- check is on, so the answer would not have come back successful otherwise.
- """
- google(success=True, hostname="somewhere.example")
- assert await verify_captcha("s", "t")
- assert await verify_captcha("s", "t", allowed_hosts=None)
-
-
-@pytest.mark.asyncio
-async def test_a_failed_solve_is_refused_whatever_the_hostname(google):
- """Order matters: `success` first, and the host check never rescues a no."""
- google(success=False, hostname="meshbay.org", **{"error-codes": ["timeout-or-duplicate"]})
- assert not await verify_captcha(
- "s", "t", allowed_hosts=frozenset({"meshbay.org"}))
-
-
-@pytest.mark.asyncio
-async def test_the_remote_ip_still_rides_along(google):
- """Unchanged behaviour, pinned because the signature grew a parameter."""
- google(success=True, hostname="meshbay.org")
- await verify_captcha("s", "t", "203.0.113.7", frozenset({"meshbay.org"}))
- assert _Client.sent == [{"secret": "s", "response": "t", "remoteip": "203.0.113.7"}]
-
-
-@pytest.mark.asyncio
-async def test_a_transport_failure_is_a_refusal_not_an_exception(monkeypatch):
- """Unchanged, and worth keeping: a captcha that cannot be checked is not a
- captcha that passed."""
- class _Boom(_Client):
- async def post(self, url, data=None):
- raise RuntimeError("no route to host")
-
- monkeypatch.setattr("httpx.AsyncClient", _Boom({}))
- assert not await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"}))
-
-
-@pytest.mark.asyncio
-async def test_the_flag_alone_checks_nothing_without_an_allowlist(google):
- """`allowed_hosts` empty still means "do not check", flag or no flag —
- the whole block is skipped and reCAPTCHA is doing the origin check."""
- google(success=True, hostname="somewhere.example")
- assert await verify_captcha("s", "t", allow_unattributed=False)
- assert await verify_captcha("s", "t", allow_unattributed=True)
-
-
-def test_the_config_hands_over_a_set_or_nothing():
- """`host_check` is what the caller passes straight through, so "unset" has
- to arrive as None rather than an empty set that refuses everything."""
- assert CaptchaConfig().host_check is None
- assert CaptchaConfig(allowed_hosts=[]).host_check is None
- assert CaptchaConfig(allowed_hosts=["meshbay.org", "meshbay"]).host_check == frozenset(
- {"meshbay.org", "meshbay"})
-
-
-def test_hub_toml_carries_the_allowed_hosts(tmp_path):
- """An operator who turns off the key's origin check sets this, and it is
- the only place they can: the value depends on the deployment."""
- cfg_file = tmp_path / "hub.toml"
- cfg_file.write_text(
- '[captcha]\n'
- 'site_key = "6Lsite"\n'
- 'secret_key = "6Lsecret"\n'
- 'allowed_hosts = ["meshbay.org", " meshbay ", "", "localhost"]\n', encoding="utf-8")
-
- cfg = load_config(cfg_file)
-
- assert cfg.captcha.enabled
- assert cfg.captcha.host_check == frozenset({"meshbay.org", "meshbay", "localhost"}), (
- "entries are stripped and blanks dropped — a stray space in a TOML "
- "list would otherwise refuse every solve from that host")
-
-
-def test_a_hub_toml_without_the_key_checks_nothing(tmp_path):
- """The upgrade path. A hub that never heard of this setting keeps the
- behaviour it has, with reCAPTCHA doing the origin check."""
- cfg_file = tmp_path / "hub.toml"
- cfg_file.write_text('[captcha]\nsite_key = "6Lsite"\nsecret_key = "6Lsecret"\n',
- encoding="utf-8")
-
- assert load_config(cfg_file).captcha.host_check is None
-
-
-def test_the_unattributed_flag_comes_from_the_config(tmp_path, monkeypatch):
- cfg_file = tmp_path / "hub.toml"
- cfg_file.write_text(
- '[captcha]\nsite_key = "k"\nsecret_key = "s"\n'
- 'allowed_hosts = ["meshbay.org"]\nallow_unattributed_host = true\n', encoding="utf-8")
- assert load_config(cfg_file).captcha.allow_unattributed_host is True
-
- cfg_file.write_text('[captcha]\nsite_key = "k"\nsecret_key = "s"\n', encoding="utf-8")
- assert load_config(cfg_file).captcha.allow_unattributed_host is False, (
- "the default has to stay off — it is the looser of the two")
-
- monkeypatch.setenv("MESHBAY_CAPTCHA_ALLOW_UNATTRIBUTED_HOST", "true")
- assert load_config(cfg_file).captcha.allow_unattributed_host is True
-
-
-def test_the_environment_can_override_the_list(monkeypatch, tmp_path):
- """Same shape as MESHBAY_ADMIN_USERS, for a container that has no file."""
- monkeypatch.setenv("MESHBAY_CAPTCHA_ALLOWED_HOSTS", "meshbay.org, meshbay ,")
-
- cfg = load_config(tmp_path / "absent.toml")
-
- assert cfg.captcha.host_check == frozenset({"meshbay.org", "meshbay"})
diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py
index c2ea4ca..7a26dfb 100644
--- a/packages/meshbay-hub/tests/test_desktop_shell.py
+++ b/packages/meshbay-hub/tests/test_desktop_shell.py
@@ -197,14 +197,9 @@ def _policy() -> str:
"""
import re
source = _main()
- # The array mixes plain strings and one `${RECAPTCHA_SRC}` template literal;
- # resolve the constant so every directive reads as plain text.
- rec = re.search(r"const RECAPTCHA_SRC = '([^']*)'", source)
match = re.search(r"const CSP = \[(.*?)\]\.join", source, re.S)
assert match, "no CSP constant in the main process"
body = match.group(1)
- if rec:
- body = body.replace("${RECAPTCHA_SRC}", rec.group(1))
# Skip the `//` comments inside the array. Reading one as a directive is
# the mistake this file already records against the packaged unit test,
# which matched the comment explaining why `User=` was absent: parse
@@ -226,24 +221,22 @@ def _directive(name: str) -> str:
def test_the_hub_is_reachable_but_never_executable():
"""
- The page connects to its own files and to reCAPTCHA, nothing else: every
+ The page connects to its own files, nothing else: every
hub call leaves from the main process (a page on `app://` is refused by
CORS anyway), and the node arrives over WebRTC, which connect-src does not
govern. A bare `https:` there let injected script post anything anywhere.
script-src never names the hub: nothing it returns is ever executed. The
- only script sources are 'self', the wasm eval token, and the two reCAPTCHA
- hosts (see the next test) — never a bare `https:` scheme, which would let
- the hub's own origin serve script.
+ only script sources are 'self' and the wasm eval token — never a bare
+ `https:` scheme, which would let the hub's own origin serve script.
"""
connect = _directive("connect-src").split()[1:]
- assert set(connect) == {"'self'", "https://www.google.com", "https://www.gstatic.com"}
+ assert set(connect) == {"'self'"}
script = _directive("script-src")
assert script, "no script-src directive"
sources = script.split()[1:] # drop the "script-src" keyword itself
allowed = {
"'self'", "'wasm-unsafe-eval'",
- "https://www.google.com", "https://www.gstatic.com",
}
assert set(sources) <= allowed, \
f"unexpected script-src source: {set(sources) - allowed}"
@@ -252,22 +245,17 @@ def test_the_hub_is_reachable_but_never_executable():
assert "default-src 'none'" in _policy()
-def test_recaptcha_is_the_only_third_party_and_stays_scoped_to_it():
+def test_no_third_party_origin_in_the_policy():
"""
- reCAPTCHA gates sign-up in the app the same way it does in the browser.
- www.google.com and www.gstatic.com are allowed under script-src, frame-src
- and img-src for that — and no other external origin appears anywhere in the
- policy. Remove this expectation only alongside the reCAPTCHA widget.
+ Nothing outside the application's own files is reachable from the page.
+ The one exception there was, reCAPTCHA's two Google origins under
+ script-src, frame-src and img-src, went with the captcha: sign-up is gated
+ by a proof of work the hub issues itself (meshbay_hub/captcha.py).
"""
- hosts = {"https://www.google.com", "https://www.gstatic.com"}
- for directive in ("script-src", "frame-src", "img-src"):
- srcs = set(_directive(directive).split()[1:])
- assert hosts <= srcs, f"{directive} is missing a reCAPTCHA host"
-
for part in _policy().split(";"):
for tok in part.strip().split()[1:]:
- if tok.startswith(("http://", "https://")):
- assert tok in hosts, f"unexpected external origin in CSP: {tok}"
+ assert not tok.startswith(("http://", "https://")), (
+ f"external origin in CSP: {tok}")
def test_the_pdf_preview_has_both_permissions_it_needs():
diff --git a/packages/meshbay-hub/tests/test_register_captcha.py b/packages/meshbay-hub/tests/test_register_captcha.py
index 5f94b9c..b63b197 100644
--- a/packages/meshbay-hub/tests/test_register_captcha.py
+++ b/packages/meshbay-hub/tests/test_register_captcha.py
@@ -1,27 +1,49 @@
-"""Registration CAPTCHA is enforced for every fresh account when configured.
+"""Sign-up and passphrase reset are gated by the hub's own proof of work.
-The gate used to be skipped whenever the request carried an `auth_key` — which
-every real client sends (the password split) — so it protected nobody and a bot
-skipped it by including the field. It now runs on `captcha.enabled` alone; the
-desktop client is Chromium and renders the same widget.
+The gate replaced reCAPTCHA, which sent every visitor's address and browser
+details to Google and needed a consent the sign-up form could not ask for
+freely. What it must still be: unconditional when enabled — the old gate was
+once skipped for any request carrying an `auth_key`, which every real client
+sends — and an answer the hub issued, in date, and used once.
+
+The last test runs the page's own solver (`static/captcha.js`) against a
+challenge from `captcha.issue`, so the two halves cannot drift apart unseen.
"""
+import hashlib
+import json
+import shutil
+import subprocess
+import time
+from pathlib import Path
+
import pytest
+from meshbay_hub import captcha
+from meshbay_hub.config import load_config
+
+STATIC = Path(captcha.__file__).parent / "static"
@pytest.fixture
def captcha_on(client, monkeypatch):
- """Turn on a fake captcha: any config with both keys is `enabled`, and
- verification succeeds only for the token 'good-token'."""
+ """The check on, with little enough work that the tests solve it at once."""
from meshbay_hub.api.users import _cfg
- monkeypatch.setattr(_cfg.captcha, "site_key", "test-site")
- monkeypatch.setattr(_cfg.captcha, "secret_key", "test-secret")
+ monkeypatch.setattr(_cfg.captcha, "enabled", True)
+ monkeypatch.setattr(_cfg.captcha, "max_number", 2000)
+ monkeypatch.setattr(captcha, "_spent", {})
+
- async def fake_verify(secret, token, remote_ip=None, allowed_hosts=None,
- allow_unattributed=False):
- return token == "good-token"
+def _solve(ch: dict) -> str:
+ for n in range(ch["max_number"]):
+ if hashlib.sha256(f"{ch['prefix']}{n}".encode()).hexdigest() == ch["challenge"]:
+ return f"{ch['prefix']}{n}.{ch['signature']}"
+ raise AssertionError("no number matches the challenge")
- monkeypatch.setattr("meshbay_hub.captcha.verify_captcha", fake_verify)
+
+async def _token(client) -> str:
+ r = await client.get("/v1/users/captcha")
+ assert r.status_code == 200, r.text
+ return _solve(r.json())
def _body(**over):
@@ -31,29 +53,111 @@ def _body(**over):
@pytest.mark.asyncio
-async def test_missing_captcha_rejected_even_with_auth_key(client, captcha_on):
+async def test_missing_answer_rejected_even_with_auth_key(client, captcha_on):
r = await client.post("/v1/users/register", json=_body())
assert r.status_code == 400
assert r.json()["detail"] == "captcha_required"
@pytest.mark.asyncio
-async def test_bad_captcha_rejected(client, captcha_on):
+async def test_solved_challenge_accepted(client, captcha_on):
r = await client.post("/v1/users/register",
- json=_body(captcha_token="wrong"))
+ json=_body(captcha_token=await _token(client)))
+ assert r.status_code == 201, r.text
+
+
+@pytest.mark.asyncio
+async def test_an_answer_is_good_for_one_form(client, captcha_on):
+ token = await _token(client)
+ r = await client.post("/v1/users/register", json=_body(captcha_token=token))
+ assert r.status_code == 201, r.text
+ r = await client.post("/v1/users/register", json=_body(
+ username="another_test", email="another@t.com", captcha_token=token))
assert r.status_code == 400
assert r.json()["detail"] == "captcha_failed"
@pytest.mark.asyncio
-async def test_good_captcha_accepted(client, captcha_on):
- r = await client.post("/v1/users/register",
- json=_body(captcha_token="good-token"))
- assert r.status_code == 201
+async def test_a_wrong_number_is_refused(client, captcha_on):
+ nonce, expires, number, sig = (await _token(client)).split(".")
+ forged = f"{nonce}.{expires}.{int(number) + 1}.{sig}"
+ r = await client.post("/v1/users/register", json=_body(captcha_token=forged))
+ assert r.json()["detail"] == "captcha_failed"
+
+
+@pytest.mark.asyncio
+async def test_a_challenge_the_hub_did_not_sign_is_refused(client, captcha_on):
+ """Somebody minting their own easy challenge has no key to sign it with."""
+ forged = f"{'00' * 12}.{int(time.time()) + 60}.0.{'0' * 64}"
+ r = await client.post("/v1/users/register", json=_body(captcha_token=forged))
+ assert r.json()["detail"] == "captcha_failed"
@pytest.mark.asyncio
-async def test_no_captcha_configured_still_registers(client):
- # Default test config has no captcha keys — registration proceeds without one.
+async def test_an_expired_answer_is_refused(client, captcha_on, monkeypatch):
+ token = await _token(client)
+ later = time.time() + captcha.CHALLENGE_TTL + 1
+ monkeypatch.setattr(captcha.time, "time", lambda: later)
+ r = await client.post("/v1/users/register", json=_body(captcha_token=token))
+ assert r.json()["detail"] == "captcha_failed"
+
+
+@pytest.mark.asyncio
+async def test_password_reset_request_is_gated_too(client, captcha_on):
+ body = {"username": "nobody_test", "email": "nobody@t.com"}
+ r = await client.post("/v1/users/password/reset-request", json=body)
+ assert r.json()["detail"] == "captcha_required"
+ r = await client.post("/v1/users/password/reset-request",
+ json={**body, "captcha_token": await _token(client)})
+ assert r.status_code == 200, r.text
+
+
+@pytest.mark.asyncio
+async def test_turned_off_it_issues_nothing_and_checks_nothing(client):
+ # The test configuration has it off (conftest.py).
+ r = await client.get("/v1/users/captcha")
+ assert r.status_code == 404
+ assert r.json()["detail"] == "captcha_disabled"
r = await client.post("/v1/users/register", json=_body())
assert r.status_code == 201
+
+
+def test_on_unless_the_configuration_says_otherwise(tmp_path):
+ cfg_file = tmp_path / "hub.toml"
+ cfg_file.write_text('[server]\nport = 8000\n', encoding="utf-8")
+ assert load_config(cfg_file).captcha.enabled
+ cfg_file.write_text('[captcha]\nenabled = false\n', encoding="utf-8")
+ assert not load_config(cfg_file).captcha.enabled
+
+
+def test_old_recaptcha_keys_are_ignored_with_a_warning(tmp_path, monkeypatch):
+ # Caught at the logger rather than through caplog: other tests in the suite
+ # reconfigure logging, and caplog then sees nothing.
+ from meshbay_hub import config
+ warned = []
+ monkeypatch.setattr(config.log, "warning", lambda msg, *a: warned.append(msg))
+ cfg_file = tmp_path / "hub.toml"
+ cfg_file.write_text('[captcha]\nsite_key = "6Lsite"\nsecret_key = "6Lsecret"\n',
+ encoding="utf-8")
+ cfg = load_config(cfg_file)
+ assert cfg.captcha.enabled
+ assert any("no longer read" in m for m in warned)
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="node is not available")
+@pytest.mark.asyncio
+async def test_the_page_solver_answers_the_hub(client, captcha_on, tmp_path):
+ ch = (await client.get("/v1/users/captcha")).json()
+ (tmp_path / "package.json").write_text('{"type":"module"}', encoding="utf-8")
+ (tmp_path / "captcha.js").write_text(
+ (STATIC / "captcha.js").read_text(encoding="utf-8"), encoding="utf-8")
+ (tmp_path / "case.js").write_text(
+ "import { solve } from './captcha.js';\n"
+ f"console.log(JSON.stringify(await solve({json.dumps(ch)})));\n", encoding="utf-8")
+ proc = subprocess.run(["node", "case.js"], cwd=tmp_path,
+ capture_output=True, text=True, encoding="utf-8")
+ assert proc.returncode == 0, proc.stderr
+ token = json.loads(proc.stdout)
+ assert token == _solve(ch)
+ r = await client.post("/v1/users/register", json=_body(captcha_token=token))
+ assert r.status_code == 201, r.text
diff --git a/packages/meshbay-hub/tests/test_security_headers.py b/packages/meshbay-hub/tests/test_security_headers.py
index b068656..e6d431e 100644
--- a/packages/meshbay-hub/tests/test_security_headers.py
+++ b/packages/meshbay-hub/tests/test_security_headers.py
@@ -64,12 +64,12 @@ def test_the_policy_is_locked_down_where_it_matters():
assert "https:" not in script.split()
-def test_recaptcha_is_the_only_external_origin():
- hosts = {"https://www.google.com", "https://www.gstatic.com"}
+def test_no_external_origin():
+ """The sign-up check is the hub's own proof of work, not a third party's."""
for part in CSP.split(";"):
for tok in part.strip().split()[1:]:
- if tok.startswith(("http://", "https://")):
- assert tok in hosts, f"unexpected external origin in CSP: {tok}"
+ assert not tok.startswith(("http://", "https://")), (
+ f"external origin in CSP: {tok}")
def test_the_streamed_download_frame_is_allowed():
@@ -78,8 +78,8 @@ def test_the_streamed_download_frame_is_allowed():
The streamed-download path works by navigating a hidden iframe to
`/_mbdl/<id>` so the service worker is asked for the response it is already
- holding. `frame-src` was tightened to reCAPTCHA's two origins when the
- captcha needed a frame, and nobody connected the two: Chrome refused the
+ holding. `frame-src` was tightened to a third-party captcha's two origins
+ when it needed a frame, and nobody connected the two: Chrome refused the
frame, the worker was never asked, and the page waited out its timeout for
a download that could not happen. On Firefox and Safari that is the *only*
way to write a large file to disk — there is no File System Access API and
diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
index aabcf45..f78f327 100644
--- a/packages/meshbay-hub/tests/test_unauthenticated_surface.py
+++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
@@ -27,10 +27,11 @@ PUBLIC = {
("GET", "/"): "the application shell",
("GET", "/app"): "the application shell",
("GET", "/app/{path:path}"): "the application shell",
- ("GET", "/v1/hub/info"): "read before sign-in: captcha key, instance policy",
+ ("GET", "/v1/hub/info"): "read before sign-in: instance policy",
("GET", "/v1/hub/version"): "an installed client checks its minimum version first",
("GET", "/v1/hub/pubkey"): "nodes cache the hub key on first contact",
("GET", "/v1/health"): "supervision",
+ ("GET", "/v1/users/captcha"): "a proof-of-work challenge for sign-up and reset, per-IP limit",
("POST", "/v1/users/register"): "obtains an account — captcha, per-IP limit",
("POST", "/v1/users/login"): "obtains a session — per-IP limit, per-name lockout",
("POST", "/v1/users/auth"): "device sign-in — Ed25519 signature over a fresh timestamp",