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
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
|
"""
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
# The registry ships closed (`relay.RELAYS_ENABLED`); the proof it demands
# is still what will be wanted the day it opens.
monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True)
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_every_relay_route_is_closed_as_the_hub_ships(client):
"""Nothing in the tree uses the registry, and two of its routes take no account.
A dependency on the router, so a route added later is closed too. The flag
is read as shipped, not set here — a test that closes the gate itself
would keep passing the day somebody opens it.
"""
admin = await _make_user(client, "relayadmin")
from meshbay_hub.api.deps import set_admin_usernames
set_admin_usernames(["relayadmin"])
auth = {"Authorization": f"Bearer {admin['token']}"}
for method, path in (("get", "/v1/relays"),
("post", "/v1/relays/register"),
("post", "/v1/relays/approve")):
kwargs = {"headers": auth} if method == "get" else {"json": {}, "headers": auth}
r = await getattr(client, method)(path, **kwargs)
assert r.status_code == 503, (path, r.status_code, r.text)
@pytest.mark.asyncio
async def test_a_stranger_who_locks_your_name_does_not_sign_you_out(client, db_session):
"""AV26. The sign-in lockout is keyed by username, and usernames are public.
So anyone can spend your attempts, and the design has to make that cost as
little as possible: a lockout refuses *passphrase* sign-in and nothing else.
The session you already have keeps working and keeps renewing, and a reset
code sent to your own address ends the lockout at once.
"""
victim_key = base64.b64encode(b"k" * 32).decode()
r = await client.post("/v1/users/register", json={
"username": "victim26", "email": "victim26@example.test", "auth_key": victim_key})
assert r.status_code == 201, r.text
session = (await client.post("/v1/users/login", json={
"username": "victim26", "auth_key": victim_key})).json()
# The stranger needs no account at all — only the name.
for _ in range(4):
r = await client.post("/v1/users/login", json={
"username": "victim26", "auth_key": "guess" + "x" * 39})
assert r.status_code == 401
r = await client.post("/v1/users/login", json={
"username": "victim26", "auth_key": victim_key})
assert r.status_code == 429, r.text
# Still signed in, and still able to stay signed in.
auth = {"Authorization": f"Bearer {session['access_token']}"}
assert (await client.get("/v1/users/me", headers=auth)).status_code == 200
r = await client.post("/v1/users/token/refresh",
json={"refresh_token": session["refresh_token"]})
assert r.status_code == 200, r.text
# The way out that needs nobody's help: a code to the address on file.
from meshbay_hub.db.models import EmailVerification, User
from sqlalchemy import select
r = await client.post("/v1/users/password/reset-request", json={
"username": "victim26", "email": "victim26@example.test"})
assert r.status_code == 200, r.text
uid = (await db_session.execute(
select(User.id).where(User.username == "victim26"))).scalar_one()
code = (await db_session.execute(select(EmailVerification.code).where(
EmailVerification.user_id == uid,
EmailVerification.purpose == "password_reset"))).scalar_one()
new_key = base64.b64encode(b"n" * 32).decode()
r = await client.post("/v1/users/password/reset", json={
"username": "victim26", "code": code, "new_auth_key": new_key})
assert r.status_code == 200, r.text
r = await client.post("/v1/users/login", json={
"username": "victim26", "auth_key": new_key})
assert r.status_code == 200, r.text
@pytest.mark.asyncio
async def test_a_captured_relay_registration_is_not_replayable(client, monkeypatch):
"""Same reason /v1/nodes/announce bounds its timestamp."""
from meshbay_hub.api import relay as relay_mod
monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True)
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)
# ── Mail: three paths out of the hub, one of them unmetered ──────────────────
@pytest.mark.asyncio
async def test_changing_your_address_cannot_mail_strangers_at_will(
client, monkeypatch):
"""
`PATCH /v1/users/me` is the third path that makes the hub send mail, and
it was the one with no rate limit and no captcha — while `register` and
`password/reset-request` have both. The address is any string the caller
types, and the duplicate check only rejects one already held by an account
here, so every address *not* registered on this hub was a valid target.
"""
sent: list = []
import meshbay_hub.mail as mail_mod
monkeypatch.setattr(mail_mod, "send_email_change_code",
lambda *a, **kw: sent.append(a))
user = await _make_user(client, "av_mailer")
headers = {"Authorization": f"Bearer {user['token']}"}
r = await client.patch("/v1/users/me", headers=headers,
json={"email": "a-stranger@example.test"})
assert r.status_code == 200, r.text
assert len(sent) == 1
r = await client.patch("/v1/users/me", headers=headers,
json={"email": "another-stranger@example.test"})
assert r.status_code == 429, r.text
assert len(sent) == 1, "the hub mailed a second stranger on demand"
@pytest.mark.asyncio
async def test_a_reset_mail_lands_once_per_account_per_window(client, monkeypatch):
"""Knowing the username/email pair is the hard part, and this endpoint is
careful about it. Once someone does, the cost of repeating lands in a
mailbox that is not theirs — and the rate limit above counts by IP."""
sent: list = []
import meshbay_hub.mail as mail_mod
monkeypatch.setattr(mail_mod, "send_password_reset_code",
lambda *a, **kw: sent.append(a))
user = await _make_user(client, "av_resettee")
body = {"username": user["username"], "email": "av_resettee@example.test"}
for _ in range(3):
r = await client.post("/v1/users/password/reset-request", json=body)
assert r.status_code == 200, r.text
assert len(sent) == 1, f"{len(sent)} reset mails for one account in one window"
def test_no_mail_is_sent_from_the_event_loop():
"""
`smtplib` is synchronous and waits up to ten seconds. Called straight from
an async handler — which is what all four call sites did — that wait is not
one request's, it is the whole hub's: nothing else is served, no node
socket is read, no offer relayed, until the MTA answers.
Read from the source because the failure has no symptom a test can catch:
everything works, slowly, for everyone, whenever the mail server is having
a bad day.
"""
import pathlib
import re as _re
root = pathlib.Path(__file__).resolve().parents[1] / "src" / "meshbay_hub"
# A direct *call* — `mail.send_x(`. A bare `mail.send_x` with no paren is
# the function being handed to send_off_loop, which is the point. The
# first version of this matched those continuation lines and so failed on
# the fixed code: look for the call, not for the name.
direct_call = _re.compile(r"\bmail\.send_(?!off_loop)\w+\s*\(")
offenders = []
for path in root.rglob("*.py"):
if path.name == "mail.py":
continue
for n, line in enumerate(path.read_text().splitlines(), 1):
if direct_call.search(line):
offenders.append(f"{path.name}:{n}: {line.strip()}")
assert not offenders, (
"these call a blocking SMTP send directly; use mail.send_off_loop:\n"
+ "\n".join(offenders))
# ── One account's rows are not the whole table ───────────────────────────────
@pytest.mark.asyncio
async def test_the_preference_namespace_is_not_open(client):
"""
`default_tab:` accepted any suffix, on a `{key:path}` route, with an
unbounded Text value: one account could write unbounded rows into a table
shared with everyone. The suffix is a group id — that is what the SPA
writes — so it is checked as one.
"""
user = await _make_user(client, "av_prefs")
headers = {"Authorization": f"Bearer {user['token']}"}
gid = await _make_group(client, user, "prefs-group")
r = await client.put(f"/v1/users/me/preferences/default_tab:{gid}",
headers=headers, json={"value": "files"})
assert r.status_code == 200, r.text
for bad in ("default_tab:" + "x" * 300, "default_tab:not-a-uuid",
"default_tab:", "default_tab:../../etc"):
r = await client.put(f"/v1/users/me/preferences/{bad}",
headers=headers, json={"value": "files"})
assert r.status_code == 400, f"{bad!r} was accepted: {r.text}"
r = await client.put(f"/v1/users/me/preferences/default_tab:{gid}",
headers=headers, json={"value": "f" * 5000})
assert r.status_code == 422, r.text
@pytest.mark.asyncio
async def test_a_list_cannot_be_asked_for_the_whole_table(client):
"""Every list in admin.py carries `le=200`. These two did not — and the
public group directory takes no authentication at all."""
user = await _make_user(client, "av_lister")
headers = {"Authorization": f"Bearer {user['token']}"}
r = await client.get("/v1/notifications?limit=1000000", headers=headers)
assert r.status_code == 422, r.text
r = await client.get("/v1/notifications?limit=-1", headers=headers)
assert r.status_code == 422, r.text
r = await client.get("/v1/groups?limit=1000000")
assert r.status_code == 422, r.text
r = await client.get("/v1/groups?offset=-5")
assert r.status_code == 422, r.text
r = await client.get("/v1/notifications?limit=20", headers=headers)
assert r.status_code == 200, r.text
# ── A node that hosts nothing is not a free target ──────────────────────────
@pytest.mark.asyncio
async def test_a_node_hosting_nothing_is_not_brokered_to_a_stranger(client):
"""
Signaling read its membership check as `if node_group_ids:` — so when the
set was empty, the membership check, the group-status check and the
public-group gate were all skipped and the offer was relayed.
Since AV1, an empty claim is the *normal* registration of a node that hosts
nothing: the unconfigured node left running, which is the machine in this
register's founding incident. Each offer makes it allocate an
`RTCPeerConnection` and gather ICE, which is finding H6 restored in exactly
the case AV1 made common — and the stranger paying nothing for it.
"""
from meshbay_hub.api import revocation as rev
owner = await _make_user(client, "av_sig_owner")
stranger = await _make_user(client, "av_sig_stranger")
node_id = await _announce_node(client, owner)
class _FakeWS:
def __init__(self):
self.sent = []
async def send_text(self, text):
self.sent.append(text)
ws = _FakeWS()
rev._connected_nodes[node_id] = ws
rev._node_groups[node_id] = [] # hosts nothing, as in the incident
try:
r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer",
json={"sdp": "v=0\r\noffer", "ice_candidates": []},
headers={"Authorization": f"Bearer {stranger['token']}"})
assert r.status_code == 403, r.text
assert not ws.sent, (
"the node was made to negotiate for someone with no group on it")
finally:
rev._connected_nodes.pop(node_id, None)
rev._node_groups.pop(node_id, None)
@pytest.mark.asyncio
async def test_a_member_still_reaches_the_node_they_share_a_group_with(client):
"""The other half, so the test above is about the claim and not about
refusing everyone."""
from meshbay_hub.api import revocation as rev
owner = await _make_user(client, "av_sig_owner2")
member = await _make_user(client, "av_sig_member2")
group_id = await _make_group(client, owner, "shared-one")
await _add_member(client, owner, group_id, member)
node_id = await _announce_node(client, owner)
answered = []
class _AnsweringWS:
async def send_text(self, text):
import json as _json
from meshbay_hub.api.signaling import handle_webrtc_answer
msg = _json.loads(text)
answered.append(msg)
handle_webrtc_answer({"peer_id": msg["peer_id"], "sdp": "v=0\r\nanswer",
"ice_candidates": []}, node_id)
rev._connected_nodes[node_id] = _AnsweringWS()
rev._node_groups[node_id] = [group_id]
try:
r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer",
json={"sdp": "v=0\r\noffer", "ice_candidates": []},
headers={"Authorization": f"Bearer {member['token']}"})
assert r.status_code == 200, r.text
assert answered
finally:
rev._connected_nodes.pop(node_id, None)
rev._node_groups.pop(node_id, None)
# ── A private group's hosts are not public knowledge ────────────────────────
@pytest.mark.asyncio
async def test_a_private_groups_node_list_is_for_its_members(client):
"""
`GET /v1/groups/{id}/nodes` checked membership only for a public group with
public groups switched off. A private group answered any authenticated
account that knew the id — which an ex-member knows for ever — with the ids
and public keys of the machines hosting it.
§7.4 already states the property for the public case: a non-member is handed
no node to connect to. This is that sentence, for the groups the whole
design optimises for.
"""
from meshbay_hub.api import revocation as rev
owner = await _make_user(client, "av_nodes_owner")
member = await _make_user(client, "av_nodes_member")
stranger = await _make_user(client, "av_nodes_stranger")
group_id = await _make_group(client, owner, "private-hosts")
await _add_member(client, owner, group_id, member)
node_id = await _announce_node(client, owner)
rev._node_groups[node_id] = [group_id]
rev._connected_nodes[node_id] = object()
try:
for who in (owner, member):
r = await client.get(
f"/v1/groups/{group_id}/nodes",
headers={"Authorization": f"Bearer {who['token']}"})
assert r.status_code == 200, r.text
assert [n["node_id"] for n in r.json()["nodes"]] == [node_id]
r = await client.get(
f"/v1/groups/{group_id}/nodes",
headers={"Authorization": f"Bearer {stranger['token']}"})
assert r.status_code == 403, (
"a stranger who knows the group id learned which machines host it: "
+ r.text)
finally:
rev._connected_nodes.pop(node_id, None)
rev._node_groups.pop(node_id, None)
async def _announce_key(client, user: dict, sk) -> int:
"""Announce a *distinct* node key, and return the status code."""
from meshbay_common.crypto import pk_to_b64
pk = pk_to_b64(sk.public_key())
ts = int(time.time())
msg = f"meshbay:node_announce:{user['user_id']}:{pk}:{ts}".encode()
r = await client.post("/v1/nodes/announce", json={
"pk_node": pk, "endpoint_hint": "test", "timestamp": ts,
"signature": base64.b64encode(sk.sign(msg)).decode(),
}, headers={"Authorization": f"Bearer {user['token']}"})
return r.status_code
async def test_one_account_cannot_announce_unlimited_nodes(client, monkeypatch):
"""
Each new node key is a row in `nodes` and a row in the IP log, and the IP log
is kept for a year. Proof of possession (M8) settles *whose* key it is and
says nothing about how many: an account in a loop wrote a year of storage on
the operator's disk having paid only for signatures.
Two accounts, because the ceiling has to be per account. One that is shared
would let a single member deny every other member the ability to bring a
machine online, which is the same defect with better manners.
"""
from meshbay_hub.api import nodes as nodes_api
monkeypatch.setattr(nodes_api, "MAX_NODES_PER_ACCOUNT", 3)
alice = await _make_user(client, "av_nodecap_alice")
bob = await _make_user(client, "av_nodecap_bob")
keys = [Ed25519PrivateKey.generate() for _ in range(4)]
for sk in keys[:3]:
assert await _announce_key(client, alice, sk) == 201
assert await _announce_key(client, alice, keys[3]) == 409, (
"an account announced past the ceiling")
# Bob has announced nothing and must be unaffected.
assert await _announce_key(client, bob, Ed25519PrivateKey.generate()) == 201, (
"one account's ceiling was charged to another's"
)
async def test_a_node_at_the_ceiling_can_still_refresh_its_address(client, monkeypatch):
"""
The ceiling counts rows, so it must be checked only where a row is added.
Applied to every announce, it would freeze the address of every node an
account already runs the moment it reached the limit — and a node that
cannot re-announce is a node nobody can reach after their ISP renumbers
them, which is an outage caused by the protection.
"""
from meshbay_hub.api import nodes as nodes_api
monkeypatch.setattr(nodes_api, "MAX_NODES_PER_ACCOUNT", 2)
alice = await _make_user(client, "av_nodecap_refresh")
keys = [Ed25519PrivateKey.generate() for _ in range(2)]
for sk in keys:
assert await _announce_key(client, alice, sk) == 201
assert await _announce_key(client, alice, Ed25519PrivateKey.generate()) == 409
for sk in keys:
assert await _announce_key(client, alice, sk) == 201, (
"a node already known could not re-announce at the ceiling")
|