diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_node_keys_several.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_node_keys_several.py | 440 |
1 files changed, 440 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_node_keys_several.py b/packages/meshbay-hub/tests/test_node_keys_several.py new file mode 100644 index 0000000..8334ff8 --- /dev/null +++ b/packages/meshbay-hub/tests/test_node_keys_several.py @@ -0,0 +1,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} |