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/src/meshbay_node/transport/webrtc_server.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/src/meshbay_node/transport/webrtc_server.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 13 |
1 files changed, 12 insertions, 1 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 10dfcb0..416e84c 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -390,6 +390,13 @@ class WebRTCPeerSession: "type": MNP.HANDSHAKE_CHALLENGE, "v": MNP_VERSION, "nonce": base64.b64encode(self._gek_challenge).decode(), + # Announced here because a first-time joiner needs it *before* the + # ack: join_request signs a transcript naming this node, and someone + # who has never held the GEK cannot complete the handshake to learn + # it. Unverified at this point — the ack proves it, the client checks + # the two match, and a wrong value only makes our own verification + # fail. It is never a substitute for the ack's proof and signature. + "node_pk": self._node_pk_b64(), }) def _do_handshake_response(self, msg: dict) -> None: @@ -731,7 +738,10 @@ class WebRTCPeerSession: return await self._pin_and_admit( - roster, user_id, username, pk_ed_b64, pk_x_b64, + # The name comes from the invitation, not from the token: the hub does + # not put a username claim in a JWT, so pinning from the session alone + # left the roster nameless and `member revoke <name>` unable to match. + roster, user_id, invite["username"] or username, pk_ed_b64, pk_x_b64, group_id=invite["group_id"], role=invite["role"], approved_by=invite["created_by"], via="code") await self._join_ok(user_id, pk_x_raw, invite["group_id"], @@ -1332,6 +1342,7 @@ class WebRTCPeerSession: role=ROLE_MEMBER, created_by=self._user_id or "", ttl=self._ctx.get("invite_ttl", DEFAULT_INVITE_TTL), + username=payload.get("username", ""), ) invites = await roster.list_invites() expires = next( |