diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 01:27:57 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 01:27:57 +0200 |
| commit | 8f6e2f724fd24a077de11d4a3b3ae069d369324d (patch) | |
| tree | 25b06d11f74b7b73e2056e1bb75c1b64af6c9650 /packages/meshbay-node/tests | |
| parent | f15efd23f66c521ca9206789482bb38e7326eeb4 (diff) | |
| download | meshbay-8f6e2f724fd24a077de11d4a3b3ae069d369324d.tar.gz | |
feat(node): operator surface — member list, invite, revoke, unpin over SSH
A node admits people from its own roster, and until now a headless operator had
no way to put anyone on it: pairing worked from the CLI, everything else needed a
browser on a machine that does not have one. Absorbs milestones 14.3/14.4.
member list who is admitted, role, status, when and how pinned
member invite <username> one-time code; the node wraps the key when they
connect, so nobody has to be online then
member revoke <username> stop serving them the key
member unpin <username> forget the pin so they can pair again after a reset
All of it goes through the daemon's loopback API with the per-run session token
(11.5.3) — _daemon_api() in daemon.py, which also replaced three hand-rolled
urllib blocks. `status` deliberately still reads the keystore, config and roster
directly, so it works while the daemon is stopped.
Two things the commands say out loud, because getting them wrong is silent:
- revoke ends by telling the operator to rotate the key. The ex-member stops
receiving it on their next connection, but they hold the current one, and
"revoked" reads like it took the key back.
- revoke/unpin refuse a username the roster does not know instead of acting on
nobody. A typo must not look like success.
Code lifetimes now differ by what the act is: 7 days for an invitation, which
crosses a human conversation and gets answered whenever someone reads their
messages, and 24 h for operator pairing, which is typed during the SSH session
that printed it. Both configurable ([node] invite_ttl_hours, pair_ttl_hours). A
day was long enough for the second and not for the first — a code that dies over
a weekend means finding a browser to issue another one.
The roster is also in the local admin UI, escaped: usernames come from the hub
and land on the page that can re-key groups and read the audit log, so H2's rule
covers them exactly as it covers filenames.
Verified by driving the real CLI against a stub daemon over a socket, which is
how the "known: <nothing>" bug in the not-found path turned up.
Tests: 89 node here (roster, endpoints, CLI routing, TTL config).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_roster_pairing.py | 190 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_security_regressions.py | 23 |
2 files changed, 213 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index e0492ae..11704ce 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -445,6 +445,52 @@ async def test_revoked_member_stops_receiving_the_key(tmp_path, roster): assert _last(session).get("gek") is False +# ── Code lifetimes ──────────────────────────────────────────────────────────── + +async def test_invitations_outlive_pairing_codes(roster): + """ + An invitation crosses a human conversation; a pairing code crosses an SSH + session. A day was long enough for the second and not for the first — a code + that dies over a weekend means someone has to be at a browser to reissue it. + """ + from meshbay_node.roster import DEFAULT_INVITE_TTL, DEFAULT_PAIR_TTL + + assert DEFAULT_INVITE_TTL == 7 * 24 * 3600 + assert DEFAULT_PAIR_TTL == 24 * 3600 + assert DEFAULT_INVITE_TTL > DEFAULT_PAIR_TTL + + +def test_code_lifetimes_are_configurable(tmp_path): + """The operator decides, not the default.""" + from meshbay_node.config import load_config + + path = tmp_path / "node.toml" + path.write_text( + '[hub]\nurl = "https://example.org"\nusername = "grenet"\n' + "[node]\ninvite_ttl_hours = 72\npair_ttl_hours = 2\n" + ) + cfg = load_config(path) + assert cfg.node.invite_ttl_hours == 72 + assert cfg.node.pair_ttl_hours == 2 + + default = load_config(tmp_path / "missing.toml") + assert default.node.invite_ttl_hours == 168 + assert default.node.pair_ttl_hours == 24 + + +async def test_expiry_is_enforced_at_redemption(tmp_path, roster): + """Purging is housekeeping; the check that matters happens on use.""" + session = _session(tmp_path, roster) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli", ttl=-1) + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code)) + + assert _last(session).get("reason") == "code_invalid" + assert await roster.get_identity("grenet") is None + + # ── M3: where node authority comes from ─────────────────────────────────────── async def test_admin_signature_verified_against_the_paired_key(tmp_path, roster): @@ -475,6 +521,150 @@ async def test_unpinned_operator_loses_authority_immediately(tmp_path, roster): assert not await session._verify_admin_sig(transcript, sk_ed.sign(transcript)) +# ── Operator surface (slice 3) ──────────────────────────────────────────────── + +def _ui_client(tmp_path, roster, **extra): + from fastapi.testclient import TestClient + + from meshbay_node.config import Config + from meshbay_node.ui.app import create_ui_app + + state = { + "status": "running", "groups_ctx": {GROUP: {"gek": b"k" * 32}}, + "indexes": {}, "ui_token": "tok", "roster": roster, + "node_user_id": "grenet", "config": Config(), + } + state.update(extra) + return TestClient(create_ui_app(state)), state + + +async def test_revoke_endpoint_stops_authorization(tmp_path, roster): + client, _ = _ui_client(tmp_path, roster) + _, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("bob", "bob", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP, "bob", ROLE_MEMBER, "active", "grenet") + assert await roster.is_authorized(GROUP, "bob") + + resp = client.post(f"/api/members/bob/revoke?group_id={GROUP}&t=tok") + assert resp.status_code == 200 + assert "gek-init" in resp.json()["reminder"], ( + "revocation must remind the operator to rotate the key they still hold") + assert not await roster.is_authorized(GROUP, "bob") + + +async def test_unpin_endpoint_allows_repairing(tmp_path, roster): + client, _ = _ui_client(tmp_path, roster) + _, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("bob", "bob", pk_ed_b64, pk_x_b64, "code") + + assert client.post("/api/members/bob/unpin?t=tok").status_code == 200 + assert await roster.get_identity("bob") is None + assert client.post("/api/members/bob/unpin?t=tok").status_code == 404 + + +async def test_operator_surface_needs_the_session_token(tmp_path, roster): + """11.5.3 applies to every one of these: they change who may hold the key.""" + client, _ = _ui_client(tmp_path, roster) + for path in ("/api/roster", + "/api/operator/pair", + f"/api/members/bob/revoke?group_id={GROUP}", + "/api/members/bob/unpin", + f"/api/groups/{GROUP}/invites?username=bob"): + method = client.get if path == "/api/roster" else client.post + assert method(path).status_code == 403, f"{path} reachable without a token" + + +async def test_cli_invite_asks_the_hub_for_an_account_never_a_key(tmp_path, roster): + """ + The CLI resolves a username to an account id through the hub, and stops there. + A key fetched from the hub is what H3 was; an account id is not a secret and + a wrong one produces an invite whose code the hub never learns. + """ + class _Hub: + _session = object() + + async def get_user_pubkeys(self, username): + return {"user_id": f"id-of-{username}", + "pk_x25519": "SHOULD-NOT-BE-USED", + "pk_ed25519": "SHOULD-NOT-BE-USED"} + + client, _ = _ui_client(tmp_path, roster, hub=_Hub()) + resp = client.post(f"/api/groups/{GROUP}/invites?username=bob&t=tok") + assert resp.status_code == 200 + body = resp.json() + assert body["user_id"] == "id-of-bob" + + invites = await roster.list_invites() + assert [i["user_id"] for i in invites] == ["id-of-bob"] + # Whatever the hub said about keys was never stored anywhere. + assert "SHOULD-NOT-BE-USED" not in str(invites) + assert await roster.get_identity("id-of-bob") is None + + +def _run_cli(monkeypatch, tmp_path, argv, responses): + """Drive the real CLI with the daemon API stubbed, capturing the calls.""" + import sys as _sys + + from meshbay_node import daemon as _daemon + + calls = [] + + def fake_api(cfg, path, method="GET", timeout=30): + calls.append((method, path)) + for key, value in responses.items(): + if key in path: + return value + return {} + + monkeypatch.setattr(_daemon, "_daemon_api", fake_api) + + conf = tmp_path / "node.toml" + conf.write_text( + f'data_dir = "{tmp_path}"\n' + '[hub]\nurl = "https://example.org"\nusername = "grenet"\n' + f'[[groups]]\nid = "{GROUP}"\nname = "demo"\n' + f'shared_dir = "{tmp_path}"\n' + ) + monkeypatch.setattr(_sys, "argv", + ["meshbay-node", *argv, "--config", str(conf)]) + try: + _daemon.main() + except SystemExit as e: + calls.append(("exit", e.code)) + return calls + + +def test_cli_member_commands_reach_the_right_endpoints(monkeypatch, tmp_path, capsys): + roster_reply = {"identities": [{"user_id": "u-bob", "username": "bob", + "pk_ed25519": "K", "pinned_at": "now", + "pinned_via": "code"}], + "members": [{"group_id": GROUP, "user_id": "u-bob", + "role": "member", "status": "active"}], + "invites": []} + + calls = _run_cli(monkeypatch, tmp_path, ["member", "revoke", "bob"], + {"/api/roster": roster_reply, + "revoke": {"status": "revoked", "reminder": "gek-init"}}) + assert ("POST", f"/api/members/u-bob/revoke?group_id={GROUP}") in calls + # The operator is told the revocation does not take back the key they hold. + assert "rotate" in capsys.readouterr().out.lower() + + calls = _run_cli(monkeypatch, tmp_path, ["member", "unpin", "bob"], + {"/api/roster": roster_reply, "unpin": {"status": "unpinned"}}) + assert ("POST", "/api/members/u-bob/unpin") in calls + + +def test_cli_refuses_to_act_on_someone_it_does_not_know(monkeypatch, tmp_path, capsys): + """A typo must not silently do nothing — or worse, act on the wrong person.""" + calls = _run_cli(monkeypatch, tmp_path, ["member", "revoke", "nobody"], + {"/api/roster": {"identities": [], "members": [], + "invites": []}}) + assert ("exit", 1) in calls + assert not any(method == "POST" for method, _ in calls), ( + "the CLI acted on the server despite not knowing who was meant") + assert "not pinned" in capsys.readouterr().out + + def test_daemon_does_not_auto_pin_keystore_key(): """ M3: the daemon used to auto-pin its own keystore key as the admin key, while diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index 6bb680c..dcd9cf6 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -579,3 +579,26 @@ def test_admin_ui_escapes_filenames(tmp_path): assert payload not in html, "filename rendered unescaped — stored XSS (H2)" assert "<img" in html, "filename should appear escaped" + +def test_admin_ui_escapes_roster_usernames(tmp_path): + """ + H2 again, for the roster: usernames originate at the hub and land on the + operator's own admin page, which can re-key groups and read the audit log. + """ + from meshbay_node.ui.app import _render_page + + payload = '<img src=x onerror="fetch(1)">' + html = _render_page( + {"status": "running", "groups_ctx": {}, "indexes": {}}, + { + "identities": {"u1": {"user_id": "u1", "username": payload, + "pk_ed25519": "AAA", "pinned_at": "now", + "pinned_via": "code"}}, + "members": [{"group_id": "", "user_id": "u1", "role": "operator", + "status": "active"}], + "invites": [], + }, + ) + + assert payload not in html, "username rendered unescaped — stored XSS (H2)" + assert "<img" in html |