""" 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()