diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
| commit | 36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch) | |
| tree | 8509ec4cf68a058f7383299e11bdea97ab06cadf /packages/meshbay-node/tests/test_device_on_connection.py | |
| parent | 8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff) | |
| download | meshbay-36cebf25d0e0f24cf63be4380ccb5d03da726a74.tar.gz | |
feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)
Chat messages are sealed with AES-256-GCM under a key derived per group, per
epoch, per *device*, and signed over the ciphertext with the device key the
node pinned. The node relays and archives; it cannot read a message.
There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a
1.x peer is refused at the handshake with `version_too_old` rather than
admitted and then unable to speak. An opt-in flag was designed and rejected:
every node is a test node, so it would have bought nothing and left a plaintext
branch reachable — C6's lesson one feature later. A test reads the source and
refuses any code that consults a `chat_encrypted` setting.
Not Sender Keys, and `senderkeys.py` is now documented as unused. With
distribution under the group key and a node that serves history to devices
which were not present, the node must retain each chain's earliest key, and a
chain key at iteration i yields every message key from i on by pure HKDF —
forward secrecy is zero either way. What the ratchet was left buying was
stateful client code with silent failure modes, three of them reproduced: any
member could sign as any other, a second device dropped the first's chain, and
the skipped-key cache grew without bound. The reasoning is in
docs/chat-sender-keys.md, which is the specification and the decision record.
Epochs, not rotation: the epoch key is wrapped under the group key at delivery
and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived
archive key would have made every message ever sent unreadable on the first
`member unpin`, which is the documented step after removing a member. A new
epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs
are kept and still delivered, so history stays readable to everyone who could
already read it, and nothing anywhere deletes one.
Three prerequisites this needed, each a live defect on its own:
* The peer registry was keyed by user_id, so one account's second device
evicted the first and the broadcast skipped recipients by account — a
person's phone never saw what they typed on their laptop.
* The handshake authenticated an account, never a device. `device_hello`
(additive, signed, refused unless the key is a live device of this account in
the node's own roster) is what lets the node refuse a member claiming
somebody else's key.
* `_admin_exec_file_delete` authorized against the exact uploading key, so
device linking had already broken deleting your own file from your other
device. It now authorizes against any non-revoked device of `uploader_id`.
Found by driving the real panel over the real transport, not by reading source:
`chat_keys_resp` was routed by arrival order and handed to an unanswered
`media_meta_req` — the original frozen-tab defect in a message type that did
not exist when that probe was written. And `_asText` had been deleted with an
unrelated helper beside it; its only caller sits inside a promise the panel
catches, so every conversation rendered empty with nothing in the console.
Existing node data is migrated by QE/migration/migrate_chat_encryption.py
(not versioned, per the QE rule), run with the node stopped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
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 |