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
|
"""
One account, several nodes: one linked key per machine the person runs a node on.
The hub used to keep one node key per account, so linking a second machine
unlinked the first, which then failed its next sign-in with nothing said. These
pin the rules that replaced it: every linked key signs in, unlinking one cuts
that machine off at once and no other, a node token can neither link nor unlink,
and no other account learns which keys an account has.
"""
import base64
import json
import time
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from sqlalchemy import create_engine, text
HUB = Path(__file__).resolve().parents[1]
def _keypair():
sk = Ed25519PrivateKey.generate()
pk = sk.public_key().public_bytes(serialization.Encoding.Raw,
serialization.PublicFormat.Raw)
return sk, base64.b64encode(pk).decode()
def _h(token):
return {"Authorization": f"Bearer {token}"}
async def _account(client, username):
_, pk_ed = _keypair()
r = await client.post("/v1/users/register", json={
"username": username, "email": f"{username}@test.local",
"password": "testpass99", "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_ed,
})
assert r.status_code == 201, r.text
r = await client.post("/v1/users/login", json={
"username": username, "password": "testpass99"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
async def _link(client, token, pk):
return await client.put("/v1/users/me/node_key", json={"pk_node_ed25519": pk},
headers=_h(token))
async def _unlink(client, token, pk):
return await client.request("DELETE", "/v1/users/me/node_key",
json={"pk_node_ed25519": pk}, headers=_h(token))
async def _node_auth(client, username, sk):
ts = int(time.time())
sig = sk.sign(f"meshbay:node_auth:{username}:{ts}".encode())
return await client.post("/v1/nodes/auth", json={
"username": username, "timestamp": ts,
"signature": base64.b64encode(sig).decode()})
async def _announce(client, node_token, user_id, sk, pk):
ts = int(time.time())
sig = sk.sign(f"meshbay:node_announce:{user_id}:{pk}:{ts}".encode())
r = await client.post("/v1/nodes/announce", json={
"pk_node": pk, "timestamp": ts, "signature": base64.b64encode(sig).decode()},
headers=_h(node_token))
assert r.status_code == 201, r.text
return r.json()["node_id"]
def _claims(token):
body = token.split(".")[1]
return json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
async def _two_nodes(client, username):
token = await _account(client, username)
sk_a, pk_a = _keypair()
sk_b, pk_b = _keypair()
assert (await _link(client, token, pk_a)).status_code == 200
assert (await _link(client, token, pk_b)).status_code == 200
return token, (sk_a, pk_a), (sk_b, pk_b)
class _FakeSocket:
def __init__(self):
self.sent, self.closed = [], None
async def send_text(self, data):
self.sent.append(json.loads(data))
async def close(self, code=1000):
self.closed = code
# ── Two machines, one account ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_two_nodes_of_one_account_both_sign_in(client):
_, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "twonodes")
ra = await _node_auth(client, "twonodes", sk_a)
rb = await _node_auth(client, "twonodes", sk_b)
assert ra.status_code == 200, ra.text
assert rb.status_code == 200, rb.text
# Each token names the key that signed in, which is what unlinking cuts.
assert _claims(ra.json()["access_token"])["nk"] == pk_a
assert _claims(rb.json()["access_token"])["nk"] == pk_b
@pytest.mark.asyncio
async def test_linking_a_second_key_keeps_the_first(client):
token, (_, pk_a), (_, pk_b) = await _two_nodes(client, "keepsfirst")
r = await client.get("/v1/users/me/node_keys", headers=_h(token))
assert r.status_code == 200
assert [k["pk_node_ed25519"] for k in r.json()["keys"]] == [pk_a, pk_b]
@pytest.mark.asyncio
async def test_an_unlinked_key_is_told_to_link_not_to_register(client):
"""The daemon reads "No node key" to wait for its link (`waiting_for_node_key`)."""
token = await _account(client, "notlinked")
_, pk_a = _keypair()
await _link(client, token, pk_a)
sk_other, _ = _keypair()
r = await _node_auth(client, "notlinked", sk_other)
assert r.status_code == 401
assert "No node key" in r.json()["detail"]
@pytest.mark.asyncio
async def test_linking_the_same_key_twice_changes_nothing(client):
token = await _account(client, "twice_test")
_, pk = _keypair()
assert (await _link(client, token, pk)).status_code == 200
assert (await _link(client, token, pk)).status_code == 200
r = await client.get("/v1/users/me/node_keys", headers=_h(token))
assert len(r.json()["keys"]) == 1
@pytest.mark.asyncio
async def test_a_key_is_stored_in_the_spelling_nodes_announce(client):
"""Pasted with spaces around it, it must still match the node's own announce."""
token = await _account(client, "spelling")
_, pk = _keypair()
r = await _link(client, token, f" {pk}\n")
assert r.status_code == 200
assert r.json()["pk_node_ed25519"] == pk
# ── Unlinking one machine ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_unlinking_one_node_cuts_it_off_and_leaves_the_other(client):
token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "unlinkone")
tok_a = (await _node_auth(client, "unlinkone", sk_a)).json()["access_token"]
tok_b = (await _node_auth(client, "unlinkone", sk_b)).json()["access_token"]
r = await _unlink(client, token, pk_a)
assert r.status_code == 200, r.text
# A's token stops working now, not in an hour…
r = await client.get("/v1/users/unlinkone/pubkeys", headers=_h(tok_a))
assert r.status_code == 401
# …and A cannot sign in again until it is linked again.
r = await _node_auth(client, "unlinkone", sk_a)
assert r.status_code == 401
assert "No node key" in r.json()["detail"]
# B is untouched.
r = await client.get("/v1/users/unlinkone/pubkeys", headers=_h(tok_b))
assert r.status_code == 200
assert (await _node_auth(client, "unlinkone", sk_b)).status_code == 200
# Linking A again lets it back in.
assert (await _link(client, token, pk_a)).status_code == 200
assert (await _node_auth(client, "unlinkone", sk_a)).status_code == 200
@pytest.mark.asyncio
async def test_unlinking_closes_that_nodes_socket_only(client):
from meshbay_hub.api import revocation
token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "sockets_test")
me = (await client.get("/v1/users/sockets_test/pubkeys", headers=_h(token))).json()["user_id"]
tok_a = (await _node_auth(client, "sockets_test", sk_a)).json()["access_token"]
tok_b = (await _node_auth(client, "sockets_test", sk_b)).json()["access_token"]
id_a = await _announce(client, tok_a, me, sk_a, pk_a)
id_b = await _announce(client, tok_b, me, sk_b, pk_b)
ws_a, ws_b = _FakeSocket(), _FakeSocket()
revocation._connected_nodes[id_a] = ws_a
revocation._connected_nodes[id_b] = ws_b
try:
listed = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()
assert [k["connected"] for k in listed["keys"]] == [True, True]
assert all(k["last_seen"] for k in listed["keys"])
assert (await _unlink(client, token, pk_a)).status_code == 200
assert ws_a.closed == 4003
assert ws_b.closed is None
assert id_a not in revocation._connected_nodes
assert revocation._connected_nodes.get(id_b) is ws_b
finally:
revocation.forget_node(id_a)
revocation.forget_node(id_b)
@pytest.mark.asyncio
async def test_a_node_token_registers_its_own_node_only(client):
"""A token names its key: it cannot register the socket of the account's other machine."""
from meshbay_hub.api.revocation import _authorize_node_ws
token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "ownnode_test")
me = (await client.get("/v1/users/ownnode_test/pubkeys", headers=_h(token))).json()["user_id"]
tok_a = (await _node_auth(client, "ownnode_test", sk_a)).json()["access_token"]
tok_b = (await _node_auth(client, "ownnode_test", sk_b)).json()["access_token"]
id_a = await _announce(client, tok_a, me, sk_a, pk_a)
id_b = await _announce(client, tok_b, me, sk_b, pk_b)
assert (await _authorize_node_ws(tok_a, id_a, []))[0] == id_a
assert (await _authorize_node_ws(tok_b, id_b, []))[0] == id_b
assert (await _authorize_node_ws(tok_a, id_b, []))[0] is None
await _unlink(client, token, pk_a)
refused, why = await _authorize_node_ws(tok_a, id_a, [])
assert refused is None and "no longer linked" in why
assert (await _authorize_node_ws(tok_b, id_b, []))[0] == id_b
@pytest.mark.asyncio
async def test_a_node_token_from_before_the_claim_still_works(client):
"""A token without `nk` predates it; it expires within the hour on its own."""
from meshbay_hub.auth import issue_access_token
token = await _account(client, "legacy_test")
me = (await client.get("/v1/users/legacy_test/pubkeys", headers=_h(token))).json()["user_id"]
old = issue_access_token(me, scope="node")
r = await client.get("/v1/users/legacy_test/pubkeys", headers=_h(old))
assert r.status_code == 200
@pytest.mark.asyncio
async def test_a_token_from_before_the_claim_cannot_bring_an_unlinked_node_back(client):
"""Found in the real run: the node held its pre-upgrade token and re-registered at once."""
from meshbay_hub.api.revocation import _authorize_node_ws
from meshbay_hub.auth import issue_access_token
token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "legacyws_test")
me = (await client.get("/v1/users/legacyws_test/pubkeys",
headers=_h(token))).json()["user_id"]
tok_a = (await _node_auth(client, "legacyws_test", sk_a)).json()["access_token"]
tok_b = (await _node_auth(client, "legacyws_test", sk_b)).json()["access_token"]
id_a = await _announce(client, tok_a, me, sk_a, pk_a)
id_b = await _announce(client, tok_b, me, sk_b, pk_b)
old = issue_access_token(me, scope="node")
assert (await _authorize_node_ws(old, id_a, []))[0] == id_a
await _unlink(client, token, pk_a)
refused, why = await _authorize_node_ws(old, id_a, [])
assert refused is None and "no longer linked" in why
assert (await _authorize_node_ws(old, id_b, []))[0] == id_b
# ── Who may link, unlink and see ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_a_node_token_cannot_link_list_or_unlink_a_sibling(client):
token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "nodescope")
tok_a = (await _node_auth(client, "nodescope", sk_a)).json()["access_token"]
_, pk_evil = _keypair()
assert (await _link(client, tok_a, pk_evil)).status_code == 403
assert (await _unlink(client, tok_a, pk_b)).status_code == 403
r = await client.get("/v1/users/me/node_keys", headers=_h(tok_a))
assert r.status_code == 403
keys = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()["keys"]
assert [k["pk_node_ed25519"] for k in keys] == [pk_a, pk_b]
@pytest.mark.asyncio
async def test_a_node_may_unlink_itself(client):
"""`meshbay-node setup --reset` and the Node page: the node lets go of its own key."""
token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "selfunlink")
tok_a = (await _node_auth(client, "selfunlink", sk_a)).json()["access_token"]
tok_b = (await _node_auth(client, "selfunlink", sk_b)).json()["access_token"]
assert (await _unlink(client, tok_a, pk_a)).status_code == 200
assert (await _node_auth(client, "selfunlink", sk_a)).status_code == 401
# A node from before the key was named in the request asks with no body.
r = await client.request("DELETE", "/v1/users/me/node_key", headers=_h(tok_b))
assert r.status_code == 200, r.text
assert r.json()["pk_node_ed25519"] == pk_b
keys = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()["keys"]
assert keys == []
@pytest.mark.asyncio
async def test_a_node_token_without_its_key_cannot_unlink(client):
from meshbay_hub.auth import issue_access_token
token, (_, pk_a), _ = await _two_nodes(client, "legacyunl")
me = (await client.get("/v1/users/legacyunl/pubkeys", headers=_h(token))).json()["user_id"]
old = issue_access_token(me, scope="node")
assert (await _unlink(client, old, pk_a)).status_code == 403
r = await client.request("DELETE", "/v1/users/me/node_key", headers=_h(old))
assert r.status_code == 403
@pytest.mark.asyncio
async def test_another_account_cannot_unlink_or_see_my_keys(client):
token, (sk_a, pk_a), _ = await _two_nodes(client, "victim_test")
mallory = await _account(client, "mallory_test")
r = await _unlink(client, mallory, pk_a)
assert r.status_code == 404
assert (await _node_auth(client, "victim_test", sk_a)).status_code == 200
r = await client.get("/v1/users/victim_test/pubkeys", headers=_h(mallory))
assert r.status_code == 200
assert "pk_node_ed25519" not in r.json()
assert "keys" not in r.json()
mine = (await client.get("/v1/users/me/node_keys", headers=_h(mallory))).json()
assert mine["keys"] == []
@pytest.mark.asyncio
async def test_installed_desktop_apps_still_see_their_own_latest_key(client):
"""Their interface shows the Node entries from `/pubkeys`; only for yourself."""
token, (_, pk_a), (_, pk_b) = await _two_nodes(client, "compatapp_test")
r = await client.get("/v1/users/compatapp_test/pubkeys", headers=_h(token))
assert r.json()["pk_node_ed25519"] == pk_b
await _unlink(client, token, pk_b)
r = await client.get("/v1/users/compatapp_test/pubkeys", headers=_h(token))
assert r.json()["pk_node_ed25519"] == pk_a
await _unlink(client, token, pk_a)
r = await client.get("/v1/users/compatapp_test/pubkeys", headers=_h(token))
assert "pk_node_ed25519" not in r.json()
@pytest.mark.asyncio
async def test_the_same_key_on_another_account_signs_in_only_as_that_account(client):
"""Linking someone's public key gains nothing: the signature names the username."""
alice = await _account(client, "keyowner")
bob = await _account(client, "keycopier")
sk, pk = _keypair()
await _link(client, alice, pk)
await _link(client, bob, pk)
await _unlink(client, bob, pk)
assert (await _node_auth(client, "keyowner", sk)).status_code == 200
@pytest.mark.asyncio
async def test_unlinking_a_key_that_is_not_linked_says_so(client):
token = await _account(client, "nothing_test")
_, pk = _keypair()
assert (await _unlink(client, token, pk)).status_code == 404
r = await client.request("DELETE", "/v1/users/me/node_key", headers=_h(token))
assert r.status_code == 422
@pytest.mark.asyncio
async def test_the_limit_is_the_announce_limit(client):
from meshbay_hub.api.nodes import MAX_NODES_PER_ACCOUNT
token = await _account(client, "manynodes")
keys = [_keypair()[1] for _ in range(MAX_NODES_PER_ACCOUNT)]
for pk in keys:
assert (await _link(client, token, pk)).status_code == 200
r = await _link(client, token, _keypair()[1])
assert r.status_code == 409
assert "Unlink one" in r.json()["detail"]
# Linking one already there still answers, at the limit.
assert (await _link(client, token, keys[0])).status_code == 200
listed = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()
assert listed["limit"] == MAX_NODES_PER_ACCOUNT
assert len(listed["keys"]) == MAX_NODES_PER_ACCOUNT
@pytest.mark.asyncio
async def test_deleting_the_account_unlinks_every_key(client, db_session):
import hashlib
from meshbay_hub.db.models import NodeKey
from sqlalchemy import select
name, passphrase = "goneacct_test", "a-long-enough-passphrase"
salt = hashlib.sha256(f"meshbay:auth:v1:{name}".encode()).digest()
auth_key = base64.b64encode(
hashlib.pbkdf2_hmac("sha512", passphrase.encode(), salt, 600_000, 32)).decode()
r = await client.post("/v1/users/register", json={
"username": name, "email": f"{name}@example.com", "auth_key": auth_key})
assert r.status_code in (200, 201), r.text
token = (await client.post("/v1/users/login", json={
"username": name, "auth_key": auth_key})).json()["access_token"]
sk_a, pk_a = _keypair()
await _link(client, token, pk_a)
await _link(client, token, _keypair()[1])
r = await client.request("DELETE", "/v1/users/me", headers=_h(token),
json={"auth_key": auth_key})
assert r.status_code == 200, r.text
left = (await db_session.execute(select(NodeKey))).scalars().all()
assert left == []
assert (await _node_auth(client, name, sk_a)).status_code == 401
# ── The upgrade ──────────────────────────────────────────────────────────────
def test_the_upgrade_keeps_the_key_each_account_had_linked(tmp_path, monkeypatch):
db = tmp_path / "hub.db"
monkeypatch.setenv("MESHBAY_DATABASE_URL", f"sqlite+aiosqlite:///{db}")
cfg = Config(str(HUB / "alembic.ini"))
command.upgrade(cfg, "e7f8a9b0c1d2")
engine = create_engine(f"sqlite:///{db}")
with engine.begin() as conn:
for uid, pk in (("u-linked", "PKLINKED"), ("u-none", None)):
conn.execute(text(
"INSERT INTO users (id, username, email, pw_hash, pw_salt, hub_id, "
"pk_node_ed25519) VALUES (:id, :id, '', x'00', x'00', 'h', :pk)"),
{"id": uid, "pk": pk})
command.upgrade(cfg, "f8a9b0c1d2e3")
with engine.begin() as conn:
rows = conn.execute(text(
"SELECT user_id, pk_node_ed25519 FROM node_keys")).fetchall()
assert rows == [("u-linked", "PKLINKED")]
command.downgrade(cfg, "e7f8a9b0c1d2")
with engine.begin() as conn:
back = dict(conn.execute(text("SELECT id, pk_node_ed25519 FROM users")).fetchall())
assert back == {"u-linked": "PKLINKED", "u-none": None}
|