summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:57 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:57 +0200
commit8f6e2f724fd24a077de11d4a3b3ae069d369324d (patch)
tree25b06d11f74b7b73e2056e1bb75c1b64af6c9650 /packages/meshbay-node/tests
parentf15efd23f66c521ca9206789482bb38e7326eeb4 (diff)
downloadmeshbay-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.py190
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py23
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 "&lt;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 "&lt;img" in html