diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_device_on_connection.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_device_on_connection.py | 287 |
1 files changed, 287 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_device_on_connection.py b/packages/meshbay-node/tests/test_device_on_connection.py new file mode 100644 index 0000000..3da8a8c --- /dev/null +++ b/packages/meshbay-node/tests/test_device_on_connection.py @@ -0,0 +1,287 @@ +""" +Which device is on this connection, and what depends on knowing. + +The MNP handshake authenticates a *group membership* (the GEK-HMAC) and an +*account* (the hub's token). It has never authenticated a device. While one +person meant one key on a node those were the same statement; device linking +(2026-08-18) ended that, and two things were left resolving "the account's +oldest live device" and calling it the answer: + + * `_load_pinned_pk`, whose result is recorded as `entry.uploader_pk` on every + upload — so a phone's uploads were attributed to a laptop; + * `_admin_exec_file_delete`, which authorized deletion against **that exact + key** — so a person could not delete their own file from their other device, + and the only symptom was "Signature verification failed" on their own upload + (`docs/desktop-client-v1.md` §4.8 A). + +`device_hello` closes the first: additive, signed, refused unless the key is a +live device *of this account in the node's own roster*. The second is closed by +authorizing against the account rather than the key. +""" + +import base64 +import time + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.crypto import pk_to_b64 +from meshbay_common.device import device_hello_transcript +from meshbay_common.protocol import MNP +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import open_roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root + +GROUP = "g" * 32 +NONCE = b"\x11" * 32 + + +@pytest.fixture +async def roster(tmp_path): + r = await open_roster(tmp_path) + yield r + await r.close() + + +def _keys(): + sk_ed = Ed25519PrivateKey.generate() + sk_x = Ed25519PrivateKey.generate() # stand-in; only its b64 is used + return sk_ed, pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key()) + + +def _session(tmp_path, roster, user_id="alice"): + shared = tmp_path / "shared" + shared.mkdir(exist_ok=True) + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "roots": one_root(shared), "index": index, "sk_node": index.sk_node, + "roster": roster, + "groups": {GROUP: {"gek": b"\x01" * 32, "index": index, + "roots": one_root(shared)}}, + } + session._group_id = GROUP + session._user_id = user_id + session._username = user_id + session._pinned_pk = "" + session._device_confirmed = False + session._nonce_node = NONCE + session._remote_ip = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +async def _hello(session, sk, pk_ed, *, ts=None, user_id=None): + ts = int(time.time()) if ts is None else ts + transcript = device_hello_transcript( + node_pk_b64=session._node_pk_b64(), group_id=session._group_id, + user_id=user_id or session._user_id, pk_ed25519_b64=pk_ed, + nonce_node=NONCE, ts=ts) + await session._do_device_hello({ + "pk_ed25519": pk_ed, "ts": ts, + "sig": base64.b64encode(sk.sign(transcript)).decode(), + }) + + +def _last(session): + return session.sent[-1] if session.sent else {} + + +# ── device_hello ───────────────────────────────────────────────────────────── + +async def test_a_pinned_device_identifies_itself(tmp_path, roster): + sk_a, pk_ed_a, pk_x_a = _keys() + sk_b, pk_ed_b, pk_x_b = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device") + + session = _session(tmp_path, roster) + await _hello(session, sk_b, pk_ed_b) + + assert _last(session)["type"] == MNP.DEVICE_HELLO_ACK + assert session._pinned_pk == pk_ed_b, ( + "the connection must be the device that signed, not the account's " + "oldest key") + assert session._device_confirmed is True + + +async def test_a_key_this_node_never_pinned_is_refused(tmp_path, roster): + sk_a, pk_ed_a, pk_x_a = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + sk_x, pk_ed_x, _ = _keys() + + session = _session(tmp_path, roster) + await _hello(session, sk_x, pk_ed_x) + + assert _last(session)["type"] == "error" + assert session._device_confirmed is False + + +async def test_a_revoked_device_cannot_identify_itself(tmp_path, roster): + sk_a, pk_ed_a, pk_x_a = _keys() + sk_b, pk_ed_b, pk_x_b = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device") + await roster.revoke_device("alice", pk_ed_b) + + session = _session(tmp_path, roster) + await _hello(session, sk_b, pk_ed_b) + + assert _last(session)["type"] == "error", ( + "a revoked key must stay refused — that is why revocation marks the " + "row instead of deleting it") + + +async def test_another_accounts_device_cannot_identify_here(tmp_path, roster): + """The roster lookup is scoped to *this* account, never to the key alone.""" + sk_a, pk_ed_a, pk_x_a = _keys() + sk_m, pk_ed_m, pk_x_m = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + await roster.pin_identity("mallory", "mallory", pk_ed_m, pk_x_m, via="code") + + session = _session(tmp_path, roster, user_id="alice") + await _hello(session, sk_m, pk_ed_m) + + assert _last(session)["type"] == "error" + + +async def test_a_signature_for_another_connection_does_not_transfer( + tmp_path, roster): + """`nonce_node` binds the statement to one connection (L4).""" + sk_a, pk_ed_a, pk_x_a = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + + session = _session(tmp_path, roster) + ts = int(time.time()) + transcript = device_hello_transcript( + node_pk_b64=session._node_pk_b64(), group_id=GROUP, + user_id="alice", pk_ed25519_b64=pk_ed_a, + nonce_node=b"\x99" * 32, ts=ts) # another connection's nonce + await session._do_device_hello({ + "pk_ed25519": pk_ed_a, "ts": ts, + "sig": base64.b64encode(sk_a.sign(transcript)).decode(), + }) + + assert _last(session)["type"] == "error" + + +async def test_a_stale_hello_is_refused(tmp_path, roster): + sk_a, pk_ed_a, pk_x_a = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + session = _session(tmp_path, roster) + await _hello(session, sk_a, pk_ed_a, ts=int(time.time()) - 3600) + assert _last(session)["type"] == "error" + + +async def test_a_connection_cannot_become_a_second_device(tmp_path, roster): + """ + Both keys are legitimately this account's, and it is still refused: one + connection's uploads must be attributable to one device. + """ + sk_a, pk_ed_a, pk_x_a = _keys() + sk_b, pk_ed_b, pk_x_b = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device") + + session = _session(tmp_path, roster) + await _hello(session, sk_a, pk_ed_a) + assert _last(session)["type"] == MNP.DEVICE_HELLO_ACK + await _hello(session, sk_b, pk_ed_b) + assert _last(session)["type"] == "error" + assert session._pinned_pk == pk_ed_a + + +async def test_the_late_roster_load_does_not_undo_a_confirmed_device( + tmp_path, roster): + """ + `_load_pinned_pk` is spawned at handshake and can finish *after* a fast + client has identified itself. It must not put the account's oldest key back + — a race that would have been intermittent and attributed to nothing. + """ + sk_a, pk_ed_a, pk_x_a = _keys() + sk_b, pk_ed_b, pk_x_b = _keys() + await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code") + await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device") + + session = _session(tmp_path, roster) + await _hello(session, sk_b, pk_ed_b) + await session._load_pinned_pk() # arrives late + + assert session._pinned_pk == pk_ed_b + + +# ── deletion is authorized by account, not by the exact device ─────────────── + +class _Entry: + def __init__(self, uploader_id="", uploader_pk=""): + self.uploader_id = uploader_id + self.uploader_pk = uploader_pk + + +async def test_a_second_device_can_delete_the_first_devices_upload( + tmp_path, roster): + """ + §4.8 A. Alice uploads from her phone and deletes from her desktop. Before + the fix this failed with "Signature verification failed" on her own file. + """ + sk_phone, pk_phone, pk_x_phone = _keys() + sk_desk, pk_desk, pk_x_desk = _keys() + await roster.pin_identity("alice", "alice", pk_phone, pk_x_phone, via="code") + await roster.pin_identity("alice", "alice", pk_desk, pk_x_desk, via="device") + + session = _session(tmp_path, roster) + entry = _Entry(uploader_id="alice", uploader_pk=pk_phone) + transcript = b"delete-this-file" + + assert await session._verify_uploader_sig( + entry, transcript, sk_desk.sign(transcript)) is True + + +async def test_a_stranger_still_cannot_delete_someone_elses_upload( + tmp_path, roster): + sk_alice, pk_alice, pk_x_alice = _keys() + sk_mallory, pk_mallory, pk_x_mallory = _keys() + await roster.pin_identity("alice", "alice", pk_alice, pk_x_alice, via="code") + await roster.pin_identity("mallory", "mallory", pk_mallory, pk_x_mallory, + via="code") + + session = _session(tmp_path, roster) + entry = _Entry(uploader_id="alice", uploader_pk=pk_alice) + transcript = b"delete-this-file" + + assert await session._verify_uploader_sig( + entry, transcript, sk_mallory.sign(transcript)) is False + + +async def test_a_revoked_device_can_no_longer_delete(tmp_path, roster): + """Ownership survives revocation; the revoked *key* stops being able to act.""" + sk_old, pk_old, pk_x_old = _keys() + sk_new, pk_new, pk_x_new = _keys() + await roster.pin_identity("alice", "alice", pk_old, pk_x_old, via="code") + await roster.pin_identity("alice", "alice", pk_new, pk_x_new, via="device") + await roster.revoke_device("alice", pk_old) + + session = _session(tmp_path, roster) + entry = _Entry(uploader_id="alice", uploader_pk=pk_old) + transcript = b"delete-this-file" + + assert await session._verify_uploader_sig( + entry, transcript, sk_old.sign(transcript)) is False + assert await session._verify_uploader_sig( + entry, transcript, sk_new.sign(transcript)) is True, ( + "the file is still Alice's — a retired laptop does not orphan its uploads") + + +async def test_an_entry_with_no_uploader_id_falls_back_to_the_recorded_key( + tmp_path, roster): + """An index written before `uploader_id` existed must not become undeletable.""" + sk_a, pk_a, pk_x_a = _keys() + session = _session(tmp_path, roster) + entry = _Entry(uploader_id="", uploader_pk=pk_a) + transcript = b"delete-this-file" + + assert await session._verify_uploader_sig( + entry, transcript, sk_a.sign(transcript)) is True |