summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/hub_client.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
commitc83a4f6ab0c8a83e8679e78427ae60dc29bb2c60 (patch)
treedea71c8e115742beaac5952c8c65481bbc130b07 /packages/meshbay-node/src/meshbay_node/hub_client.py
parentee6573c57f721db8550e34e1c1c79c5922c62a4b (diff)
parentd324792d68503109ab99616af6c85ee37045e169 (diff)
downloadmeshbay-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.py95
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: