diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 02:17:45 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 02:18:07 +0200 |
| commit | 71df5857b213be893025c562977558ba79009c09 (patch) | |
| tree | 1e84b347b4b1b2b4ebfdc738d46ad4f6803e2eec /packages/meshbay-node/tests/test_roster_pairing.py | |
| parent | d7120761fe8cf406f374ef769db6e1f9bf1fe287 (diff) | |
| download | meshbay-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.py | 48 |
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(): """ |