summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_chat_history_binary.py
blob: e2397a0f55bdb1a876c9789f9ea7beb69db2d8ce (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
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/MESHBAY_DESIGN.md` §4.5
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()