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_chat_history_binary.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_chat_history_binary.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_chat_history_binary.py | 181 |
1 files changed, 181 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_chat_history_binary.py b/packages/meshbay-node/tests/test_chat_history_binary.py new file mode 100644 index 0000000..18efbf5 --- /dev/null +++ b/packages/meshbay-node/tests/test_chat_history_binary.py @@ -0,0 +1,181 @@ +""" +A ciphertext must survive the history path. + +`_send_chat_history` used to put every stored payload through +`.decode("utf-8", errors="replace")`, which substitutes U+FFFD for every byte +that is not valid UTF-8 — i.e. for most of a ciphertext. Live messages are +relayed rather than re-read, so they would have kept working: the symptom would +have been "history will not decrypt" and nothing else, which is the hardest +possible place to look for a wire-format error. + +The fix keeps plaintext exactly where it has always been (a string in +`payload`, which older clients read) and gives ciphertext its own `ct` field. +That way this is not a compatibility break either — `docs/chat-sender-keys.md` +R3. +""" + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.protocol import MNP +from meshbay_node.chat import FORMAT_PLAIN, FORMAT_SEALED_V1, ChatStore +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root + +GROUP = "g" * 32 + +# Deliberately not valid UTF-8: a lone continuation byte, an over-long form and +# a bare 0xff, which is what a random AES-GCM ciphertext is full of. +CIPHERTEXT = bytes([0x80, 0xff, 0xc0, 0x80, 0xfe, 0x00, 0x41, 0xed, 0xa0, 0x80]) + + +@pytest.fixture +async def store(tmp_path): + s = ChatStore(tmp_path / "chat.db") + await s.open() + yield s + await s.close() + + +def _session(store, tmp_path): + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + shared = tmp_path / "shared" + shared.mkdir(exist_ok=True) + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = {"groups": {GROUP: { + "index": index, "roots": one_root(shared), "chat_store": store}}} + session._group_id = GROUP + session._user_id = "alice" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +async def test_a_ciphertext_survives_the_history_path(store, tmp_path): + await store.save_message( + sender_id="alice", iteration=0, payload=CIPHERTEXT, + format=FORMAT_SEALED_V1, epoch=1, device=b"\x01" * 32, + nonce=b"\x02" * 12, sig=b"\x03" * 64) + + session = _session(store, tmp_path) + await session._send_chat_history(store, None, 50) + + resp = session.sent[-1] + assert resp["type"] == MNP.CHAT_HISTORY_RESPONSE + row = resp["messages"][0] + assert row["ct"] == CIPHERTEXT, ( + "the ciphertext must come back byte for byte — decoded as UTF-8 with " + "errors='replace' it comes back as U+FFFD and nothing decrypts") + assert row["format"] == FORMAT_SEALED_V1 + assert row["epoch"] == 1 + assert row["nonce"] == b"\x02" * 12 + assert row["sig"] == b"\x03" * 64 + + +async def test_plaintext_history_keeps_the_shape_older_clients_read( + store, tmp_path): + """ + The compatibility half. The UI ships inside the desktop package now, so a + client can be months behind the node; a plaintext message must still arrive + as a string under `payload`, exactly as it always has. + """ + await store.save_message(sender_id="alice", iteration=0, + payload="bonjour ç'est moi".encode()) + + session = _session(store, tmp_path) + await session._send_chat_history(store, None, 50) + + row = session.sent[-1]["messages"][0] + assert row["payload"] == "bonjour ç'est moi" + assert isinstance(row["payload"], str) + assert row["format"] == FORMAT_PLAIN + assert "ct" not in row + + +async def test_a_mixed_history_reads_both_ways(store, tmp_path): + """ + R4: rows written before a group turned encryption on keep rendering. The + switch never rewrites anything, so every group that turns it on has a + history of both kinds for ever. + """ + await store.save_message(sender_id="alice", iteration=0, payload=b"before") + await store.save_message( + sender_id="alice", iteration=0, payload=CIPHERTEXT, + format=FORMAT_SEALED_V1, epoch=1, device=b"\x01" * 32, + nonce=b"\x02" * 12, sig=b"\x03" * 64) + + session = _session(store, tmp_path) + await session._send_chat_history(store, None, 50) + + rows = session.sent[-1]["messages"] + assert [r["format"] for r in rows] == [FORMAT_PLAIN, FORMAT_SEALED_V1] + assert rows[0]["payload"] == "before" + assert rows[1]["ct"] == CIPHERTEXT + + +async def test_an_existing_database_opens_and_keeps_its_rows(tmp_path): + """ + The migration, from the only angle that matters: a chat.db written before + the new columns existed must open, keep every row, and read back as + plaintext. `CREATE TABLE IF NOT EXISTS` adds no column to a table that is + already there — the same trap `create_all()` is recorded for on the hub. + """ + import aiosqlite + + path = tmp_path / "old_chat.db" + async with aiosqlite.connect(str(path)) as db: + await db.execute( + "CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, " + "sender_id TEXT NOT NULL, iteration INTEGER NOT NULL, " + "payload BLOB NOT NULL, timestamp REAL NOT NULL, " + "thread_id TEXT DEFAULT NULL, sender_name TEXT DEFAULT '')") + await db.execute( + "INSERT INTO messages (sender_id, iteration, payload, timestamp) " + "VALUES ('alice', 0, ?, 1700000000.0)", (b"an old message",)) + await db.commit() + + store = ChatStore(path) + await store.open() + try: + rows = await store.get_recent(10) + assert len(rows) == 1 + assert rows[0].payload == b"an old message" + assert rows[0].format == FORMAT_PLAIN + assert rows[0].epoch == 0 + assert rows[0].device is None + # And it is still writable, including with the new columns. + await store.save_message( + sender_id="bob", iteration=0, payload=CIPHERTEXT, + format=FORMAT_SEALED_V1, epoch=1, device=b"\x09" * 32, + nonce=b"\x08" * 12, sig=b"\x07" * 64) + assert await store.message_count() == 2 + finally: + await store.close() + + +async def test_opening_twice_keeps_every_column(tmp_path): + """ + The migrations are swallowed per statement, not per batch: one shared + `try` would stop at the first already-present column and silently skip + every later one, so a node upgraded twice would be missing the newest + fields with nothing to show for it. + """ + path = tmp_path / "twice.db" + for _ in range(2): + store = ChatStore(path) + await store.open() + await store.close() + + store = ChatStore(path) + await store.open() + try: + await store.save_message( + sender_id="alice", iteration=0, payload=CIPHERTEXT, + format=FORMAT_SEALED_V1, epoch=3, device=b"\x01" * 32, + nonce=b"\x02" * 12, sig=b"\x03" * 64) + row = (await store.get_recent(1))[0] + assert row.epoch == 3 and row.sig == b"\x03" * 64 + finally: + await store.close() |