aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_device_on_connection.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
commit36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch)
tree8509ec4cf68a058f7383299e11bdea97ab06cadf /packages/meshbay-node/tests/test_device_on_connection.py
parent8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff)
downloadmeshbay-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.py287
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