diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 19:35:37 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 19:35:37 +0200 |
| commit | c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60 (patch) | |
| tree | dea71c8e115742beaac5952c8c65481bbc130b07 /packages/meshbay-node/src/meshbay_node/hub_client.py | |
| parent | ee6573c57f721db8550e34e1c1c79c5922c62a4b (diff) | |
| parent | d324792d68503109ab99616af6c85ee37045e169 (diff) | |
| download | meshbay-c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60.tar.gz | |
merge: Phase 11.5 security remediation, invite redesign, per-node identity
Brings in the security remediation branch. Three bodies of work, and what they
changed about what this project may claim.
Phase 11.5 closed the gap between the documents and the code: the unauthenticated
node HTTP API and the TCP transport deleted, one handshake shared by the
remaining two transports, mutual authentication, structured admin transcripts,
upload confinement, group isolation, revocation that reaches nodes. Six critical
and seven high findings closed, bounded, or deferred by decision.
The invite redesign closed H3 and M3 — the last open High. The hub was the key
directory: an inviter fetched the invitee's key from it and wrapped the group key
for whatever came back, so a hub answering with its own key was handed the group
key by an honest member following the protocol exactly. That lookup is gone. The
node holds the group key and wraps it itself, for a key its recipient proves
possession of, bound to an account by a one-time code the hub never sees. M3 fell
out of the same work: node authority comes from a local roster, never from the
hub.
Per-node identity cut what remains of C4 down to one operator. A single keypair
used to be copied to every node its owner joined; each node now gets its own, so
cracking the bundle on one machine yields a key that is a stranger everywhere
else — and on that machine, one that unlocks nothing its holder did not already
serve. The bundle KDF moved to Argon2id 128 MB, and the hub stopped storing or
publishing user keys at all.
What this project may now say: the hub cannot read your content unless it ships
you malicious client code. T3 remains, accepted (D1), and is what the native
client removes. C4 is reduced, not closed, until 13.3. Chat is still plaintext at
rest until Phase 15. Draft-v5 §2 states each claim against the adversary it holds
against, which is the convention this branch exists to keep.
Four defects were found by deploying it and using a browser, none by the test
suite: a node going deaf on its hub socket, a token that predated group
membership, a client reading values before they were assigned, and identity keys
a browser held but never re-read. The lessons are recorded in CLAUDE.md.
Tests: 343 across the three packages, plus QE/deploy/e2e.py — register, pair,
invite, join, download, stream, second browser, revoke — run against the live
deployment on a wiped hub and node.
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/hub_client.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/hub_client.py | 95 |
1 files changed, 79 insertions, 16 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 432af0a..d8417e3 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -13,6 +13,7 @@ No auth_key or password is ever stored on or transmitted from the node. The hub issues a node-scoped JWT that cannot manage group membership. """ +import asyncio import base64 import json import logging @@ -127,8 +128,8 @@ class HubClient: access_token = data["access_token"] decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"]) - assert decoded["pk_user"] == self._keys.pk_ed25519_b64, \ - "Hub returned token for wrong public key" + # No pk_user claim to check any more: tokens carry no key. What binds this + # token to this node is the Ed25519 challenge it was issued against. assert "jti" in decoded, "Hub token missing jti — hub is outdated" assert decoded.get("scope") == "node", \ "Expected node-scoped token" @@ -163,9 +164,19 @@ class HubClient: raise RuntimeError("Not logged in") await self.ensure_fresh_token() + # Proof of possession of the node key (M8) — same domain-separated shape + # as node_auth, so a signature for one can never satisfy the other. + timestamp = int(time.time()) + message = (f"meshbay:node_announce:{self._session.user_id}:" + f"{self._keys.pk_ed25519_b64}:{timestamp}").encode() + signature = base64.b64encode( + self._keys.sk_ed25519.sign(message)).decode() + r = await self._http.post("/v1/nodes/announce", json={ "pk_node": self._keys.pk_ed25519_b64, "endpoint_hint": endpoint_hint, + "timestamp": timestamp, + "signature": signature, }, headers=self._session.auth_headers) r.raise_for_status() node_id = r.json()["node_id"] @@ -219,9 +230,19 @@ class HubClient: hub_url = self._session.hub_url.replace("https://", "wss://").replace("http://", "ws://") ws_url = f"{hub_url}/v1/nodes/ws" + # Offers are handled off the read loop (see below), so keep a handle on + # the tasks to avoid them being garbage-collected mid-negotiation. + pending: set[asyncio.Task] = set() + while True: try: - async with websockets.connect(ws_url) as ws: + # Explicit keepalive: this connection is how a node stays visible + # to the hub, and a silently half-open socket looks exactly like a + # working one until someone notices the node has vanished. + async with websockets.connect( + ws_url, ping_interval=20, ping_timeout=20, close_timeout=5, + open_timeout=15, + ) as ws: auth_msg = { "type": "auth", "token": self._session.access_token, @@ -230,10 +251,20 @@ class HubClient: if group_ids: auth_msg["group_ids"] = group_ids await ws.send(json.dumps(auth_msg)) - auth_resp = json.loads(await ws.recv()) + # Bounded: a hub that accepts the socket and then says nothing + # — which is what it does for a few seconds while restarting — + # would otherwise park this task here forever, with the node + # running, silent, and invisible to everyone. + auth_resp = json.loads( + await asyncio.wait_for(ws.recv(), timeout=15)) if auth_resp.get("type") != "auth_ok": - log.error("WS auth failed: %s", auth_resp) - return + # Not fatal: the token may simply have expired while we + # were disconnected. Refresh on the next pass rather than + # ending the task, which used to strand the node for good. + log.warning("WS auth refused: %s — retrying in 5s", auth_resp) + await asyncio.sleep(5) + await self.ensure_fresh_token() + continue self._ws = ws log.info("Hub WS connected") @@ -250,28 +281,60 @@ class HubClient: on_revocation(msg.get("token", "")) elif mtype == "webrtc_offer" and on_webrtc_offer: - answer = await on_webrtc_offer( - msg["sdp"], msg["peer_id"], - msg.get("ice_candidates", [])) - if answer: - await ws.send(json.dumps({ - "type": "webrtc_answer", - "peer_id": msg["peer_id"], - "sdp": answer[0], - "ice_candidates": answer[1], - })) + # Answered off the read loop on purpose. Awaiting the + # handler here meant one slow negotiation stopped the + # node reading this socket at all: no pings answered, + # no close frame noticed, no further offers served. A + # client that gave up mid-ICE left the node in + # CLOSE-WAIT, still running but invisible to the hub + # and unreachable by everyone, until it was restarted. + task = asyncio.create_task( + self._answer_offer(ws, on_webrtc_offer, msg)) + pending.add(task) + task.add_done_callback(pending.discard) elif mtype == "pong": pass except asyncio.CancelledError: + for task in pending: + task.cancel() raise except Exception as e: log.warning("Hub WS disconnected: %s — reconnecting in 5s", e) await asyncio.sleep(5) + else: + # A clean close ends the `async for` without raising. Say so, so a + # node that quietly stopped being reachable leaves a trace. + log.warning("Hub WS closed by the hub — reconnecting in 5s") + await asyncio.sleep(5) finally: self._ws = None + async def _answer_offer(self, ws, on_webrtc_offer, msg: dict) -> None: + """Negotiate one WebRTC offer and return the answer, off the read loop.""" + try: + answer = await on_webrtc_offer( + msg["sdp"], msg["peer_id"], msg.get("ice_candidates", [])) + except Exception as e: + log.warning("WebRTC offer from %s failed: %s", + str(msg.get("peer_id"))[:8], e) + return + if not answer: + return + try: + await ws.send(json.dumps({ + "type": "webrtc_answer", + "peer_id": msg["peer_id"], + "sdp": answer[0], + "ice_candidates": answer[1], + })) + except Exception as e: + # The socket may have gone while we were negotiating; the client will + # retry, and the read loop is reconnecting. + log.warning("Could not deliver WebRTC answer to %s: %s", + str(msg.get("peer_id"))[:8], e) + # ── Swarm registration ───────────────────────────────────────────────── async def register_swarm(self, content_hashes: list[str], endpoint: str) -> int: |