summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_register_captcha.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 13:34:47 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 13:34:47 +0200
commit51705056a578d2649a989bf14a056fbcfae58a6b (patch)
treedfd078aba518427387fca29b9d43c3723b030008 /packages/meshbay-hub/tests/test_register_captcha.py
parentdaec4bc6a2a9f9e038e5269586b06018ff327a27 (diff)
downloadmeshbay-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/tests/test_register_captcha.py')
-rw-r--r--packages/meshbay-hub/tests/test_register_captcha.py148
1 files changed, 126 insertions, 22 deletions
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