summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_roster_pairing.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 02:17:45 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 02:18:07 +0200
commit71df5857b213be893025c562977558ba79009c09 (patch)
tree1e84b347b4b1b2b4ebfdc738d46ad4f6803e2eec /packages/meshbay-node/tests/test_roster_pairing.py
parentd7120761fe8cf406f374ef769db6e1f9bf1fe287 (diff)
downloadmeshbay-71df5857b213be893025c562977558ba79009c09.tar.gz
fix(node): announce the node key in the challenge, and keep names in the roster
Both found by deploying the thing and running the workflow end to end. Neither was reachable from the test suite, for the same reason in each case: the tests knew something a real client cannot. 1. A first-time joiner had no way to learn node_pk. join_request signs a transcript naming the node, and the node key was only sent in handshake_ack — which an invited member cannot reach, having no GEK to prove. joinGroup() therefore threw "handshake incomplete" and the browser path for an invited member was broken. Every test built the transcript from a node key it already had, so nothing noticed. The challenge now carries node_pk. It is unverified at that point and never a substitute for the ack: the ack still proves possession and signs the transcript, the client checks the two values match and refuses a peer that changed identity mid-handshake, and TOFU pinning is unchanged. A wrong value only makes our own verification fail. test_invite_then_join_delivers_the_gek now takes the key from the challenge instead of from sk_node, so it proves a real client can learn it. 2. The roster pinned everyone without a name. `_do_join_request` took the username from the session, which takes it from the JWT — and the hub puts no username claim in a token. So identities were pinned with an empty name and `member revoke <name>` could never match: the live node answered "known: , ,". Invitations now carry the name (new invites.username column, with a migration for the roster DBs already out there), and the CLI resolves a name through the daemon: its own roster first, the hub as fallback for identities pinned before this. The harness that found them is QE/deploy/e2e.py — gitignored with the rest of QE/, so it is not in this commit. It does the SPA's job in Python against the live deployment: hub login, WebRTC via hub signaling, the unified handshake, joining with a code, index, chunk download and MSE segments. Verified against meshbay.org and the local node: an account registered from scratch is invited by code, receives the group key wrapped for a key it proved it holds, downloads and decrypts a file, streams 5 encrypted fMP4 segments, reconnects with no code, and is refused after `member revoke`. The node audit log shows invite_create → join_pinned(via=code) → gek_wrapped → handshake, then join_no_gek once revoked. Tests: 232 node+common. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_roster_pairing.py')
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py48
1 files changed, 31 insertions, 17 deletions
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py
index 11704ce..d13225d 100644
--- a/packages/meshbay-node/tests/test_roster_pairing.py
+++ b/packages/meshbay-node/tests/test_roster_pairing.py
@@ -445,6 +445,22 @@ async def test_revoked_member_stops_receiving_the_key(tmp_path, roster):
assert _last(session).get("gek") is False
+# ── What a first-time joiner can know ─────────────────────────────────────────
+
+def test_challenge_carries_node_pk_in_source():
+ """
+ Belt and braces for the above: the field must be in the message the node
+ builds, whatever the surrounding handshake does.
+ """
+ source = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "transport" / "webrtc_server.py").read_text()
+ challenge = source[source.find("MNP.HANDSHAKE_CHALLENGE,"):]
+ challenge = challenge[:challenge.find("})")]
+ assert "node_pk" in challenge, (
+ "the challenge must announce the node key — a first-time joiner cannot "
+ "learn it any other way, and join_request signs it")
+
+
# ── Code lifetimes ────────────────────────────────────────────────────────────
async def test_invitations_outlive_pairing_codes(roster):
@@ -635,35 +651,33 @@ def _run_cli(monkeypatch, tmp_path, argv, responses):
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": []}
+ resolved = {"user_id": "u-bob", "source": "roster"}
calls = _run_cli(monkeypatch, tmp_path, ["member", "revoke", "bob"],
- {"/api/roster": roster_reply,
+ {"/api/resolve": resolved,
"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"}})
+ {"/api/resolve": resolved, "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_cli_resolves_a_name_before_acting(monkeypatch, tmp_path):
+ """
+ The name has to be turned into an account first, and the node's own roster is
+ asked before the hub. A JWT carries no username, so an identity pinned without
+ an invitation has none — the hub fallback is what keeps it manageable.
+ """
+ calls = _run_cli(monkeypatch, tmp_path, ["member", "revoke", "bob"],
+ {"/api/resolve": {"user_id": "u-bob", "source": "hub"},
+ "revoke": {"status": "revoked", "reminder": "gek-init"}})
+ assert ("GET", "/api/resolve?username=bob") == calls[0], (
+ "the CLI must resolve the name before acting on anyone")
+ assert ("POST", f"/api/members/u-bob/revoke?group_id={GROUP}") in calls
def test_daemon_does_not_auto_pin_keystore_key():
"""