aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_node_ws_auth.py
blob: f3ac3a25bd6c9d22e005ffc90c1a0bedff0acdf8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
"""
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_test")
    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_test")
    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_test")
    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_test")
    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_test")
    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_test")
    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_test")
    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_test")
    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_test")
    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_test")
    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_test")
    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_test")
    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_test")
    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_test")
    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)")


# ── An empty claim is not a claim on everything ──────────────────────────────
#
# 2026-09-11, found on a live deployment. `_claimable` used to read
# `set(claimed_groups or authorized)`, and the node omits `group_ids` entirely
# when it hosts nothing — so "I host no groups" was read as "I host all of
# yours". The node cannot serve any of them (no GEK, and its own handshake
# refuses them), but `/v1/groups/{id}/nodes` lists nodes in registration order,
# so whenever such a node won the reconnection race after a hub restart it
# became `nodes[0]` and the group stopped opening for every member.


@pytest.mark.asyncio
async def test_ws_absent_claim_registers_no_groups(client):
    """A node that declares nothing hosts nothing — it must not inherit the set."""
    from meshbay_hub.api.revocation import _authorize_node_ws

    user = await _make_user(client, "empty1_test")
    node_id = await _announce_node(client, user)
    for name in ("has-one", "has-two"):
        r = await client.post(
            "/v1/groups",
            json={"name": name, "visibility": "private", "join_policy": "invite"},
            headers={"Authorization": f"Bearer {user['token']}"},
        )
        assert r.status_code == 201, r.text

    resolved, groups = await _authorize_node_ws(_node_token(user), node_id, None)
    assert resolved == node_id
    assert groups == [], (
        "a node hosting nothing was registered as a host for its owner's groups")


@pytest.mark.asyncio
async def test_ws_explicit_empty_claim_registers_no_groups(client):
    """And the same when the node says so out loud, which it now does."""
    from meshbay_hub.api.revocation import _authorize_node_ws

    user = await _make_user(client, "empty2_test")
    node_id = await _announce_node(client, user)
    r = await client.post(
        "/v1/groups",
        json={"name": "lonely", "visibility": "private", "join_policy": "invite"},
        headers={"Authorization": f"Bearer {user['token']}"},
    )
    assert r.status_code == 201, r.text

    resolved, groups = await _authorize_node_ws(_node_token(user), node_id, [])
    assert resolved == node_id
    assert groups == []


@pytest.mark.asyncio
async def test_empty_node_cannot_shadow_another_members_group(client):
    """
    The outage itself: two members, one group, and only one of them hosts it.
    The other's node — running, configured with nothing — must not appear as a
    source for that group, because it is the one clients would reach first.
    """
    from meshbay_hub.api.revocation import (
        _authorize_node_ws, _node_groups, get_online_nodes_for_group)

    host = await _make_user(client, "hoster_test")
    guest = await _make_user(client, "guest_test")
    host_node = await _announce_node(client, host)
    guest_node = await _announce_node(client, guest)

    r = await client.post(
        "/v1/groups",
        json={"name": "shared", "visibility": "private", "join_policy": "invite"},
        headers={"Authorization": f"Bearer {host['token']}"},
    )
    assert r.status_code == 201, r.text
    group_id = r.json()["group_id"]

    r = await client.post(
        f"/v1/groups/{group_id}/members/{'guest_test'}",
        headers={"Authorization": f"Bearer {host['token']}"},
    )
    assert r.status_code == 201, r.text

    # The guest's node registers first — the order that made this fatal.
    _, guest_groups = await _authorize_node_ws(_node_token(guest), guest_node, None)
    _, host_groups = await _authorize_node_ws(
        _node_token(host), host_node, [group_id])
    _node_groups[guest_node] = guest_groups
    _node_groups[host_node] = host_groups
    try:
        assert get_online_nodes_for_group(group_id) == [host_node], (
            "a member's empty node shadowed the node actually hosting the group")
    finally:
        _node_groups.pop(guest_node, None)
        _node_groups.pop(host_node, None)


@pytest.mark.asyncio
async def test_update_groups_is_held_to_the_same_ceiling(client):
    """
    `update_groups` assigned the message's list verbatim, so the C2 ceiling held
    at authentication could be stepped over one message later: a node had only
    to reload to claim any group on the hub. It now goes through the same gate,
    which is what this asserts — the socket loop itself needs a WebSocket the
    ASGI harness has not got (see this module's docstring).
    """
    from meshbay_hub.api.revocation import _authorized_groups, _claimable

    user = await _make_user(client, "reloader")
    r = await client.post(
        "/v1/groups",
        json={"name": "owned", "visibility": "private", "join_policy": "invite"},
        headers={"Authorization": f"Bearer {user['token']}"},
    )
    assert r.status_code == 201, r.text
    own = r.json()["group_id"]

    authorized = await _authorized_groups(user["user_id"])
    assert _claimable([own, "someone-elses-group"], authorized) == [own]
    assert _claimable([], authorized) == []
    assert _claimable(None, authorized) == []