diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/revocation.py | 97 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_node_ws_auth.py | 172 |
2 files changed, 256 insertions, 13 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index c9c59c9..0f1ddba 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -42,7 +42,7 @@ import jwt from meshbay_hub.auth import hub_public_key_pem, decode_access_token from meshbay_hub.api.deps import get_current_user, require_admin from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import Group, IPLog, User +from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User log = logging.getLogger(__name__) @@ -128,38 +128,109 @@ async def _handle_chat_notify(group_id: str, sender_name: str, sender_user_id: s log.warning("Chat notify failed: %s", e) +async def _reject(ws: WebSocket, detail: str, code: int) -> None: + await ws.send_text(json.dumps({"type": "error", "detail": detail})) + await ws.close(code=code) + + +async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tuple: + """ + Resolve a node WS registration against the database. + + Returns (node_id, group_ids) on success, or (None, error_detail) on refusal. + Uses a short-lived session on purpose: a node WebSocket lives for hours, and a + request-scoped dependency would pin a PostgreSQL connection for its whole + lifetime, exhausting the pool once a handful of nodes connect. + """ + from meshbay_hub.db.engine import get_session_factory + + try: + decoded = decode_access_token(token) + except Exception as e: + return None, str(e) + + if decoded.get("scope") != "node": + return None, "Node-scoped token required" + + user_id = decoded.get("sub", "") + if not claimed_id: + return None, "node_id required" + + async with get_session_factory()() as db: + node = await db.get(Node, claimed_id) + if node is None or node.user_id != user_id: + log.warning("Rejected WS registration for node %s by user %s", + claimed_id[:8], (user_id or "?")[:8]) + return None, "node_id does not belong to this account" + + user = await db.get(User, user_id) + if user is None or user.status != "active": + return None, "Account not active" + + # Groups come from the database. The node may narrow the set to what it + # actually hosts, but it cannot widen it to groups it is not a member of — + # otherwise it could advertise itself as a source for any group on the hub. + result = await db.execute( + select(GroupMember.group_id).where(GroupMember.user_id == user_id)) + authorized = {gid for (gid,) in result.all()} + + claimed = set(claimed_groups or authorized) + return claimed_id, sorted(authorized & claimed) + + @router.websocket("/v1/nodes/ws") async def node_websocket(ws: WebSocket): """ Persistent WebSocket connection for nodes. - Nodes authenticate with a JWT bearer in the first message. - Hub sends revocation tokens as JSON messages. + + Finding C2: this used to take `node_id` and `group_ids` straight from the + client's first message, with no check that the authenticated user owned that + node. Any registered user could connect with an ordinary browser token, claim a + victim node's id, and overwrite its entry in `_connected_nodes`. Every WebRTC + offer for that node was then relayed to the attacker, who answered with their + own SDP — a full node impersonation, and the DTLS channel binding does not help + because the attacker is the endpoint rather than a relay. The attacker received + the victim's encrypted keypair bundle, their chat, and their uploads. + + Identity now comes from the token and the database, never from the message. """ await ws.accept() node_id: str | None = None try: - # Auth: expect {"type": "auth", "token": "<jwt>"} + # Auth: expect {"type": "auth", "token": "<jwt>", "node_id": "..."} raw = await ws.receive_text() msg = json.loads(raw) if msg.get("type") != "auth" or "token" not in msg: - await ws.send_text(json.dumps({"type": "error", "detail": "Send auth first"})) - await ws.close(code=4001) + await _reject(ws, "Send auth first", 4001) return try: decoded = decode_access_token(msg["token"]) except Exception as e: - await ws.send_text(json.dumps({"type": "error", "detail": str(e)})) - await ws.close(code=4001) + await _reject(ws, str(e), 4001) + return + + claimed_id = msg.get("node_id") or "" + + # Refuse to displace a live registration rather than silently overwriting it. + if claimed_id and claimed_id in _connected_nodes: + await _reject(ws, "Node already connected", 4009) + return + + resolved_id, result = await _authorize_node_ws( + msg["token"], claimed_id, msg.get("group_ids")) + if resolved_id is None: + await _reject(ws, result, 4003) return + group_ids = result - node_id = msg.get("node_id") or decoded.get("sub", "unknown") + user_id = decoded.get("sub", "") + node_id = resolved_id _connected_nodes[node_id] = ws - group_ids = msg.get("group_ids", []) - if group_ids: - _node_groups[node_id] = group_ids - log.info("Node WS connected: %s (groups=%d)", node_id[:8], len(group_ids)) + _node_groups[node_id] = group_ids + log.info("Node WS connected: %s (user=%s, groups=%d)", + node_id[:8], user_id[:8], len(group_ids)) await ws.send_text(json.dumps({"type": "auth_ok", "node_id": node_id})) # Message loop — handle ping, punch_ready, etc. 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..6db95d7 --- /dev/null +++ b/packages/meshbay-hub/tests/test_node_ws_auth.py @@ -0,0 +1,172 @@ +""" +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} + + +async def _announce_node(client, user: dict) -> str: + 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 == 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"], user["pk_ed"], 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_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]] |