aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_chat_history_binary.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_chat_history_binary.py')
-rw-r--r--packages/meshbay-node/tests/test_chat_history_binary.py181
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()