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
|
"""
Availability: what one participant can do to the others.
The three security reviews asked who can *read* what, who can impersonate whom,
and what a hostile node can forge. None of them asked what a legitimate but
misconfigured or careless member costs everyone else — and that is the question
a group platform lives or dies on, because every member is invited by someone
who trusted them and none of them is an attacker.
Written after 2026-09-11, where a member's unconfigured node was registered by
the hub as a host for a group it could not serve, and — being answered first —
made that group unopenable for everyone in it. Nothing was compromised and
nothing was forged. The group was simply gone.
Each test below is two accounts, because that is the shape the single-node
suite could not express: `_make_user` twice, a group owned by one, the other
holding whatever the defect needs. A one-member test proves a one-member
property, and every finding here needed a second person to exist at all.
"""
import base64
import time
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:
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, "username": username,
"token": r.json()["access_token"], "pk_ed": pk_ed, "sk_ed": sk_ed}
async def _announce_node(client, user: dict) -> str:
ts = int(time.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"]
async def _make_group(client, owner: dict, name: str) -> str:
r = await client.post(
"/v1/groups",
json={"name": name, "visibility": "private", "join_policy": "invite"},
headers={"Authorization": f"Bearer {owner['token']}"})
assert r.status_code == 201, r.text
return r.json()["group_id"]
async def _add_member(client, owner: dict, group_id: str, member: dict) -> None:
r = await client.post(
f"/v1/groups/{group_id}/members/{member['username']}",
headers={"Authorization": f"Bearer {owner['token']}"})
assert r.status_code == 201, r.text
# ── A member's node must not speak for a group it does not host ──────────────
@pytest.mark.asyncio
async def test_a_members_node_cannot_notify_a_group_it_does_not_host(client):
"""
`chat_notify` carried a `group_id` the hub believed, so any connected node
could write a notification to every member of any group on the hub, with a
display string of its own choosing. The node's account needed no relation
to the group whatsoever — this is the same defect as the group claim, one
message further along the same socket.
"""
from meshbay_hub.api import revocation as rev
owner = await _make_user(client, "av_owner1")
outsider = await _make_user(client, "av_outsider1")
owner_node = await _announce_node(client, owner)
outsider_node = await _announce_node(client, outsider)
group_id = await _make_group(client, owner, "not-yours")
rev._node_groups[owner_node] = [group_id]
rev._node_groups[outsider_node] = [] # hosts nothing, as in the incident
try:
await rev._handle_chat_notify(
group_id, "Someone you do not know", outsider["user_id"],
node_id=outsider_node)
r = await client.get("/v1/notifications",
headers={"Authorization": f"Bearer {owner['token']}"})
assert r.status_code == 200, r.text
assert r.json()["notifications"] == [], (
"an unrelated node wrote into this account's notifications")
# And the node that does host it is still able to.
await rev._handle_chat_notify(
group_id, "A real member", outsider["user_id"], node_id=owner_node)
r = await client.get("/v1/notifications",
headers={"Authorization": f"Bearer {owner['token']}"})
assert len(r.json()["notifications"]) == 1
finally:
rev._node_groups.pop(owner_node, None)
rev._node_groups.pop(outsider_node, None)
@pytest.mark.asyncio
async def test_a_member_whose_node_hosts_nothing_still_cannot_notify(client):
"""
The incident's own shape, and the one a check on *membership* would have
missed: the second account is a real member of the group, invited by its
owner. Their node is simply not the one holding the files. Being entitled
to be in a group is not being entitled to speak for it.
"""
from meshbay_hub.api import revocation as rev
owner = await _make_user(client, "av_owner2")
member = await _make_user(client, "av_member2")
owner_node = await _announce_node(client, owner)
member_node = await _announce_node(client, member)
group_id = await _make_group(client, owner, "shared-room")
await _add_member(client, owner, group_id, member)
rev._node_groups[owner_node] = [group_id]
rev._node_groups[member_node] = []
try:
await rev._handle_chat_notify(
group_id, "Sounds like a member", member["user_id"],
node_id=member_node)
r = await client.get("/v1/notifications",
headers={"Authorization": f"Bearer {owner['token']}"})
assert r.json()["notifications"] == [], (
"a member's empty node wrote into the group owner's notifications")
finally:
rev._node_groups.pop(owner_node, None)
rev._node_groups.pop(member_node, None)
@pytest.mark.asyncio
async def test_one_node_cannot_spend_the_hubs_database_on_notifications():
"""
Each chat_notify is a query and a write per member of the group, spawned
without backpressure. The budget is what stops one group's node from
costing every other group on the instance.
"""
from meshbay_hub.api.revocation import (
NOTIFY_BURST, _notify_budget, _notify_window)
node = "budget-node"
_notify_window.pop(node, None)
try:
assert all(_notify_budget(node) for _ in range(NOTIFY_BURST))
assert not _notify_budget(node), "the burst was not bounded"
# A second node is unaffected — the budget is per node, not global.
assert _notify_budget("another-node")
finally:
_notify_window.pop(node, None)
_notify_window.pop("another-node", None)
@pytest.mark.asyncio
async def test_the_notify_budget_is_not_refilled_by_reconnecting(client):
"""
The obvious place to clear this is the socket's `finally`, beside the other
two registries — and that would make reconnecting the way around it. The
same node token is valid for an hour.
"""
from meshbay_hub.api import revocation as rev
user = await _make_user(client, "av_reconnect")
node_id = await _announce_node(client, user)
rev._notify_window.pop(node_id, None)
try:
for _ in range(rev.NOTIFY_BURST):
rev._notify_budget(node_id)
assert not rev._notify_budget(node_id)
# The disconnect path itself, not a re-enactment of it: `forget_node`
# is what the socket's `finally` calls, so adding a line there is
# caught here.
rev.forget_node(node_id)
assert node_id not in rev._connected_nodes
assert node_id not in rev._node_groups
assert not rev._notify_budget(node_id), (
"disconnecting refilled the budget, so reconnecting defeats it")
finally:
rev._notify_window.pop(node_id, None)
# ── A member must not aim other people's traffic ─────────────────────────────
@pytest.mark.asyncio
async def test_a_swarm_source_cannot_name_someone_elses_address(client):
"""
`endpoint` was free text documented as "ip:port", so an account could
publish a third party's address as a source for any content. Nothing dials
a swarm source today, which is the only reason this was not already the
reflection primitive that `notify_incoming` was fixed for (H6). A port is
all a reader needs: where the node is comes from the node record, which is
stamped with the address its announce arrived from.
"""
user = await _make_user(client, "av_swarm1")
headers = {"Authorization": f"Bearer {user['token']}"}
for bad in ("192.0.2.7:4433", "evil.example:53", "webrtc:0", "webrtc:70000",
"webrtc:4433 ", "http://example.test"):
r = await client.post("/v1/swarm/register", headers=headers,
json={"content_hash": "ab" * 32, "endpoint": bad})
assert r.status_code == 422, f"{bad!r} was accepted: {r.text}"
r = await client.post("/v1/swarm/register", headers=headers,
json={"content_hash": "ab" * 32, "endpoint": "webrtc:19010"})
assert r.status_code == 201, r.text
@pytest.mark.asyncio
async def test_one_account_cannot_fill_the_swarm_table(client, monkeypatch):
"""Rows are keyed (hash, account) with no cap — an invented hash each time."""
import meshbay_hub.api.groups as groups_api
monkeypatch.setattr(groups_api, "MAX_SWARM_HASHES_PER_ACCOUNT", 3)
user = await _make_user(client, "av_swarm2")
headers = {"Authorization": f"Bearer {user['token']}"}
for i in range(3):
r = await client.post("/v1/swarm/register", headers=headers,
json={"content_hash": f"{i:064x}",
"endpoint": "webrtc:19010"})
assert r.status_code == 201, r.text
r = await client.post("/v1/swarm/register", headers=headers,
json={"content_hash": f"{99:064x}", "endpoint": "webrtc:19010"})
assert r.status_code == 429, r.text
# Refreshing one already held is not a new claim and must still work.
r = await client.post("/v1/swarm/register", headers=headers,
json={"content_hash": f"{0:064x}", "endpoint": "webrtc:19011"})
assert r.status_code == 201, r.text
# ── A member's node must not answer for another's ────────────────────────────
def test_a_node_cannot_answer_an_offer_it_was_never_sent():
"""
`handle_webrtc_answer` resolved any pending `peer_id` from any node's
socket. The answer is the SDP the browser then connects to, so the check is
what keeps one node from standing in for the node a client asked for. That
it had not happened rested on a uuid4 being unguessable.
"""
import asyncio
from meshbay_hub.api.signaling import (
_answer_owner, _webrtc_answers, handle_webrtc_answer)
loop = asyncio.new_event_loop()
try:
future = loop.create_future()
_webrtc_answers["peer-1"] = future
_answer_owner["peer-1"] = "the-node-asked-for"
handle_webrtc_answer({"peer_id": "peer-1", "sdp": "v=0 impostor"},
"a-different-node")
assert not future.done(), "another node resolved this offer"
handle_webrtc_answer({"peer_id": "peer-1", "sdp": "v=0 genuine"},
"the-node-asked-for")
assert future.done() and future.result()["sdp"] == "v=0 genuine"
finally:
_webrtc_answers.pop("peer-1", None)
_answer_owner.pop("peer-1", None)
loop.close()
# ── One account must not be able to mail another at will ─────────────────────
@pytest.mark.asyncio
async def test_an_invite_email_says_what_the_hub_knows_not_what_it_is_told(
client, monkeypatch):
"""
`group_name` went from the request body into the subject line of an email
the hub sends under its own domain, and the invitee need not be a member of
anything — as they cannot be, being invited. So any account, having made a
group, could mail any other account arbitrary text. The name now comes from
the group row, and the code has to look like one.
"""
sent: list = []
import meshbay_hub.mail as mail_mod
monkeypatch.setattr(mail_mod, "send_invite_notification",
lambda *a, **kw: sent.append(a))
owner = await _make_user(client, "av_inviter")
await _make_user(client, "av_invitee")
group_id = await _make_group(client, owner, "real-group-name")
headers = {"Authorization": f"Bearer {owner['token']}"}
r = await client.post(f"/v1/groups/{group_id}/invite-notify", headers=headers,
json={"username": "av_invitee", "code": "not a code",
"group_name": "Your account is suspended"})
assert r.status_code == 422, r.text
assert not sent
r = await client.post(f"/v1/groups/{group_id}/invite-notify", headers=headers,
json={"username": "av_invitee", "code": "AB12-CD34",
"group_name": "Your account is suspended"})
assert r.status_code == 200, r.text
assert sent, "the legitimate path stopped working"
assert sent[0][3] == "real-group-name", (
"the sender chose the subject line of a message the hub signs")
# ── A relay is not authenticated by the key it publishes ─────────────────────
@pytest.mark.asyncio
async def test_a_relay_must_prove_it_holds_the_approved_key(client, monkeypatch):
"""
`relay_register` had no `Depends` and verified nothing: it compared
`pk_relay` against the approved value, which is a **public** key. Anyone
who could read it could rewrite where the hub tells nodes to send relayed
traffic — an unauthenticated write to state other people's machines act
on. The module docstring said the relay "signs keepalive JWTs"; `jwt` was
imported and never used.
"""
from meshbay_hub.api import relay as relay_mod
sk = Ed25519PrivateKey.generate()
pk = pk_to_b64(sk.public_key())
relay_mod._relays["r1"] = {"pk": pk, "active": False}
try:
# The public key alone, which used to be enough.
r = await client.post("/v1/relays/register", json={
"relay_id": "r1", "endpoint": "198.51.100.9:9999",
"pk_relay": pk, "capacity": 100})
assert r.status_code == 400, r.text
assert relay_mod._relays["r1"].get("endpoint") is None
# A signature over someone else's endpoint does not carry either: the
# endpoint is inside the signed message.
ts = int(time.time())
sig = sk.sign(f"meshbay:relay_register:r1:10.0.0.1:4433:{ts}".encode())
r = await client.post("/v1/relays/register", json={
"relay_id": "r1", "endpoint": "198.51.100.9:9999", "pk_relay": pk,
"timestamp": ts, "signature": base64.b64encode(sig).decode()})
assert r.status_code == 401, r.text
endpoint = "203.0.113.4:4433"
sig = sk.sign(f"meshbay:relay_register:r1:{endpoint}:{ts}".encode())
r = await client.post("/v1/relays/register", json={
"relay_id": "r1", "endpoint": endpoint, "pk_relay": pk,
"timestamp": ts, "signature": base64.b64encode(sig).decode()})
assert r.status_code == 201, r.text
assert relay_mod._relays["r1"]["endpoint"] == endpoint
finally:
relay_mod._relays.pop("r1", None)
@pytest.mark.asyncio
async def test_a_captured_relay_registration_is_not_replayable(client):
"""Same reason /v1/nodes/announce bounds its timestamp."""
from meshbay_hub.api import relay as relay_mod
sk = Ed25519PrivateKey.generate()
pk = pk_to_b64(sk.public_key())
relay_mod._relays["r2"] = {"pk": pk, "active": False}
try:
ts = int(time.time()) - 3600
sig = sk.sign(f"meshbay:relay_register:r2:203.0.113.5:4433:{ts}".encode())
r = await client.post("/v1/relays/register", json={
"relay_id": "r2", "endpoint": "203.0.113.5:4433", "pk_relay": pk,
"timestamp": ts, "signature": base64.b64encode(sig).decode()})
assert r.status_code == 401, r.text
finally:
relay_mod._relays.pop("r2", None)
|