summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_node_ws_auth.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-hub/tests/test_node_ws_auth.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-hub/tests/test_node_ws_auth.py')
-rw-r--r--packages/meshbay-hub/tests/test_node_ws_auth.py346
1 files changed, 346 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_node_ws_auth.py b/packages/meshbay-hub/tests/test_node_ws_auth.py
new file mode 100644
index 0000000..1391722
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_node_ws_auth.py
@@ -0,0 +1,346 @@
+"""
+Phase 11.5 security regression tests — node WebSocket registration (finding C2).
+
+The hub relays every WebRTC offer for a node to whoever holds that node's entry in
+`_connected_nodes`. That registration used to be established from a client-supplied
+`node_id` with no ownership check, so any registered user could take over a victim
+node's signaling and become the endpoint browsers connect to.
+
+These exercise `_authorize_node_ws` directly rather than through a socket: it is the
+function that makes the authorization decision, and the hub test harness uses
+ASGITransport, which has no WebSocket support.
+"""
+
+import base64
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+
+from meshbay_common.crypto import pk_to_b64
+
+
+async def _make_user(client, username: str) -> dict:
+ """Register + log in a user, returning ids, token and keys."""
+ sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate()
+ pk_ed, pk_x = pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key())
+
+ r = await client.post("/v1/users/register", json={
+ "username": username,
+ "email": f"{username}@example.test",
+ "auth_key": base64.b64encode(b"k" * 32).decode(),
+ "pk_user_ed25519": pk_ed,
+ "pk_user_x25519": pk_x,
+ })
+ assert r.status_code == 201, r.text
+ user_id = r.json()["user_id"]
+
+ r = await client.post("/v1/users/login", json={
+ "username": username,
+ "auth_key": base64.b64encode(b"k" * 32).decode(),
+ })
+ assert r.status_code == 200, r.text
+ return {"user_id": user_id, "token": r.json()["access_token"],
+ "pk_ed": pk_ed, "sk_ed": sk_ed}
+
+
+async def _announce_node(client, user: dict) -> str:
+ # Announce now requires proof of possession of the node key (M8).
+ import time as _t
+ ts = int(_t.time())
+ msg = f"meshbay:node_announce:{user['user_id']}:{user['pk_ed']}:{ts}".encode()
+ r = await client.post(
+ "/v1/nodes/announce",
+ json={
+ "pk_node": user["pk_ed"], "endpoint_hint": "test",
+ "timestamp": ts,
+ "signature": base64.b64encode(user["sk_ed"].sign(msg)).decode(),
+ },
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+ return r.json()["node_id"]
+
+
+def _node_token(user: dict) -> str:
+ from meshbay_hub.auth import issue_access_token
+ return issue_access_token(user["user_id"], scope="node")
+
+
+@pytest.mark.asyncio
+async def test_ws_rejects_user_scoped_token(client):
+ """C2: a browser token must never be able to register as a node."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ victim = await _make_user(client, "victim1")
+ node_id = await _announce_node(client, victim)
+
+ resolved, detail = await _authorize_node_ws(victim["token"], node_id, None)
+ assert resolved is None
+ assert "node-scoped" in detail.lower()
+
+
+@pytest.mark.asyncio
+async def test_ws_rejects_foreign_node_id(client):
+ """
+ C2: the impersonation itself. An attacker with a perfectly valid node-scoped
+ token of their own must not be able to claim someone else's node_id.
+ """
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ victim = await _make_user(client, "victim2")
+ attacker = await _make_user(client, "attacker2")
+ victim_node = await _announce_node(client, victim)
+ await _announce_node(client, attacker)
+
+ resolved, detail = await _authorize_node_ws(
+ _node_token(attacker), victim_node, None)
+ assert resolved is None, "attacker hijacked the victim's node registration (C2)"
+ assert "does not belong" in detail.lower()
+
+
+@pytest.mark.asyncio
+async def test_ws_rejects_unknown_node_id(client):
+ """C2: an invented node_id must not register either."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "user3")
+ resolved, _ = await _authorize_node_ws(_node_token(user), "no-such-node", None)
+ assert resolved is None
+
+
+@pytest.mark.asyncio
+async def test_ws_rejects_missing_node_id(client):
+ """C2: identity may not fall back to the token subject."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "user4")
+ resolved, _ = await _authorize_node_ws(_node_token(user), "", None)
+ assert resolved is None
+
+
+@pytest.mark.asyncio
+async def test_ws_accepts_own_node(client):
+ """The legitimate path still works."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "owner5")
+ node_id = await _announce_node(client, user)
+
+ resolved, groups = await _authorize_node_ws(_node_token(user), node_id, None)
+ assert resolved == node_id
+ assert groups == []
+
+
+@pytest.mark.asyncio
+async def test_ws_group_claims_cannot_widen_beyond_membership(client):
+ """
+ C2: `group_ids` used to be taken verbatim, letting a node advertise itself as
+ an online source for any group on the hub and attract clients to it.
+ """
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "owner6")
+ node_id = await _announce_node(client, user)
+
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "mine", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+ own_group = r.json()["group_id"]
+
+ resolved, groups = await _authorize_node_ws(
+ _node_token(user), node_id, [own_group, "someone-elses-group"])
+
+ assert resolved == node_id
+ assert groups == [own_group], "node advertised a group it is not a member of"
+
+
+@pytest.mark.asyncio
+async def test_signaling_rejects_non_member(client):
+ """
+ H6/H4: POST /v1/nodes/{id}/webrtc/offer was reachable by any authenticated
+ user for any node, with no membership check and no rate limit. Each call makes
+ the target node allocate an aiortc PeerConnection and gather ICE, so it was a
+ remote resource-exhaustion primitive against a third party's machine.
+ """
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "owner8")
+ outsider = await _make_user(client, "outsider8")
+ node_id = await _announce_node(client, owner)
+
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "private-g", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {owner['token']}"},
+ )
+ group_id = r.json()["group_id"]
+
+ # Pretend the node is connected and hosting that group.
+ class _FakeWS:
+ async def send_text(self, _):
+ raise AssertionError("offer relayed to node despite non-membership")
+
+ rev._connected_nodes[node_id] = _FakeWS()
+ rev._node_groups[node_id] = [group_id]
+ try:
+ resp = await client.post(
+ f"/v1/nodes/{node_id}/webrtc/offer",
+ json={"sdp": "v=0", "ice_candidates": []},
+ headers={"Authorization": f"Bearer {outsider['token']}"},
+ )
+ assert resp.status_code == 403, resp.text
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+ rev._node_groups.pop(node_id, None)
+
+
+@pytest.mark.asyncio
+async def test_signaling_rejects_oversized_sdp(client):
+ """H6: an SDP offer is ~2 KB; unbounded input is a memory amplifier."""
+ user = await _make_user(client, "user9")
+ resp = await client.post(
+ "/v1/nodes/whatever/webrtc/offer",
+ json={"sdp": "v=0" + ("x" * 200_000), "ice_candidates": []},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert resp.status_code == 413
+
+
+@pytest.mark.asyncio
+async def test_incoming_rejects_foreign_peer_ip(client):
+ """
+ H6: peer_ip was taken verbatim, letting any user make an arbitrary node emit
+ UDP packets to an address of their choosing — reflection via someone else's
+ machine. The probe target must be the caller's own address.
+ """
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "owner10")
+ node_id = await _announce_node(client, owner)
+
+ class _FakeWS:
+ async def send_text(self, _):
+ raise AssertionError("punch relayed with attacker-chosen peer_ip")
+
+ rev._connected_nodes[node_id] = _FakeWS()
+ try:
+ resp = await client.post(
+ f"/v1/nodes/{node_id}/incoming",
+ json={"peer_ip": "198.51.100.7", "peer_port": 9999},
+ headers={"Authorization": f"Bearer {owner['token']}"},
+ )
+ assert resp.status_code == 403, resp.text
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+
+
+@pytest.mark.asyncio
+async def test_ws_node_may_narrow_its_group_set(client):
+ """A node hosting a subset of the operator's groups may say so."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "owner7")
+ node_id = await _announce_node(client, user)
+
+ created = []
+ for name in ("g-one", "g-two"):
+ r = await client.post(
+ "/v1/groups",
+ json={"name": name, "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ created.append(r.json()["group_id"])
+
+ resolved, groups = await _authorize_node_ws(
+ _node_token(user), node_id, [created[0]])
+ assert resolved == node_id
+ assert groups == [created[0]]
+
+
+# ── M8: announce proof of possession ─────────────────────────────────────────
+
+def _announce_payload(user_id: str, sk, pk_b64: str, ts: int | None = None):
+ import time as _t
+ ts = ts if ts is not None else int(_t.time())
+ msg = f"meshbay:node_announce:{user_id}:{pk_b64}:{ts}".encode()
+ return {
+ "pk_node": pk_b64,
+ "endpoint_hint": "test",
+ "timestamp": ts,
+ "signature": base64.b64encode(sk.sign(msg)).decode(),
+ }
+
+
+@pytest.mark.asyncio
+async def test_announce_requires_proof_of_possession(client):
+ """
+ M8: /v1/nodes/announce accepted any pk_node with no proof the announcer held
+ the private key, so a user could announce a record carrying someone else's
+ node key.
+ """
+ user = await _make_user(client, "ann1")
+ r = await client.post(
+ "/v1/nodes/announce",
+ json={"pk_node": user["pk_ed"], "endpoint_hint": "test"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 400, r.text
+
+
+@pytest.mark.asyncio
+async def test_announce_rejects_foreign_key(client):
+ """M8: announcing someone else's public key must fail — no matching private key."""
+ user = await _make_user(client, "ann2")
+ victim_sk = Ed25519PrivateKey.generate()
+ victim_pk = pk_to_b64(victim_sk.public_key())
+
+ attacker_sk = Ed25519PrivateKey.generate()
+ payload = _announce_payload(user["user_id"], attacker_sk, victim_pk)
+
+ r = await client.post(
+ "/v1/nodes/announce", json=payload,
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 401, r.text
+
+
+@pytest.mark.asyncio
+async def test_announce_rejects_stale_timestamp(client):
+ """M8: a captured announce must not be replayable later."""
+ import time as _t
+ user = await _make_user(client, "ann3")
+ sk = Ed25519PrivateKey.generate()
+ payload = _announce_payload(
+ user["user_id"], sk, pk_to_b64(sk.public_key()), ts=int(_t.time()) - 3600)
+
+ r = await client.post(
+ "/v1/nodes/announce", json=payload,
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 401, r.text
+
+
+@pytest.mark.asyncio
+async def test_announce_with_valid_proof_succeeds_and_is_idempotent(client):
+ """The legitimate path works, and re-announcing updates rather than piling up rows."""
+ user = await _make_user(client, "ann4")
+ sk = Ed25519PrivateKey.generate()
+ pk_b64 = pk_to_b64(sk.public_key())
+
+ first = await client.post(
+ "/v1/nodes/announce", json=_announce_payload(user["user_id"], sk, pk_b64),
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert first.status_code == 201, first.text
+
+ second = await client.post(
+ "/v1/nodes/announce", json=_announce_payload(user["user_id"], sk, pk_b64),
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert second.status_code == 201, second.text
+ assert second.json()["node_id"] == first.json()["node_id"], (
+ "re-announcing the same key must not create a second node record (M8)")