summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/chat/store.py
blob: 9e5ee90fdf22b76f8b303fffd15758085694afa7 (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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""
MeshBay Node — SQLite-backed chat message store.

One database per group. The node is a relay and an archive: it stores what it
was handed, serves it back, and — once a group has chat encryption switched on —
cannot read any of it. Decryption happens in the client, which is the only place
that holds the epoch key (`docs/chat-sender-keys.md` §5).

Three things about the schema are load-bearing rather than incidental:

* **`payload` is bytes, always.** It used to be a UTF-8 string in practice, and
  the history path decoded it with `errors="replace"` — which substitutes
  U+FFFD for every byte that is not valid UTF-8, i.e. for most of a ciphertext.
  That would have corrupted history while live messages worked, which reads as
  an intermittent decryption bug rather than as a wire-format error.
* **`format` says how to read a row**, so messages written before a group turned
  encryption on keep rendering. Nothing is ever rewritten in place by the
  switch; see `chat encrypt-history` for the explicit, backed-up alternative.
* **`(device, nonce)` is unique.** The nonce is 96 random bits chosen per
  message by the sending device, so it is already required to be unique for
  AES-GCM to be safe — making it a key costs nothing and turns a replayed
  message (which is validly signed, being a copy of a real one) into an
  integrity error instead of a duplicate.
"""

import logging
import time
from dataclasses import dataclass
from pathlib import Path

import aiosqlite

log = logging.getLogger(__name__)


class ReplayedMessage(Exception):
    """This device has already sent a message under this nonce."""

# Plaintext, as every message was before chat encryption existed. Rows keep it
# for ever; nothing rewrites them.
FORMAT_PLAIN = 0
# Sealed under a chat epoch key: `payload` is the AES-256-GCM ciphertext,
# `nonce` its 96-bit nonce, `sig` the sender device's Ed25519 signature.
FORMAT_SEALED_V1 = 1

_SCHEMA = """
CREATE TABLE IF NOT EXISTS 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 ''
);
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(timestamp);
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
"""

# Additive, every one with a default, so an existing chat.db opens unchanged and
# a node that is downgraded still reads its own rows. `CREATE TABLE IF NOT
# EXISTS` adds no column to a table that already exists — the same trap
# `create_all()` is recorded for on the hub — so each of these runs on its own
# and a duplicate-column error is the expected outcome on the second start.
_MIGRATIONS = (
    "ALTER TABLE messages ADD COLUMN sender_name TEXT DEFAULT ''",
    "ALTER TABLE messages ADD COLUMN format INTEGER NOT NULL DEFAULT 0",
    "ALTER TABLE messages ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0",
    "ALTER TABLE messages ADD COLUMN device BLOB DEFAULT NULL",
    "ALTER TABLE messages ADD COLUMN nonce BLOB DEFAULT NULL",
    "ALTER TABLE messages ADD COLUMN sig BLOB DEFAULT NULL",
)

# A replay is a validly signed copy of a real message, so nothing about the
# signature refuses it. The nonce does: it is per message, per device, and a
# repeat is either an attack or a bug. Partial, because plaintext rows carry no
# nonce at all and NULLs are distinct in SQLite anyway.
_REPLAY_INDEX = (
    "CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_replay "
    "ON messages(device, nonce) WHERE nonce IS NOT NULL"
)


@dataclass
class StoredMessage:
    id: int
    sender_id: str
    iteration: int
    payload: bytes
    timestamp: float
    thread_id: str | None
    sender_name: str = ""
    format: int = FORMAT_PLAIN
    epoch: int = 0
    device: bytes | None = None
    nonce: bytes | None = None
    sig: bytes | None = None


_COLUMNS = ("id, sender_id, iteration, payload, timestamp, thread_id, "
            "sender_name, format, epoch, device, nonce, sig")


def _row(r) -> StoredMessage:
    return StoredMessage(
        id=r[0], sender_id=r[1], iteration=r[2], payload=r[3],
        timestamp=r[4], thread_id=r[5], sender_name=r[6] or "",
        format=r[7] or FORMAT_PLAIN, epoch=r[8] or 0,
        device=r[9], nonce=r[10], sig=r[11],
    )


class ChatStore:
    """Async SQLite chat store for one group."""

    def __init__(self, db_path: Path):
        self._db_path = db_path
        self._db: aiosqlite.Connection | None = None

    async def open(self) -> None:
        self._db_path.parent.mkdir(parents=True, exist_ok=True)
        self._db = await aiosqlite.connect(str(self._db_path))
        await self._db.executescript(_SCHEMA)
        for statement in _MIGRATIONS:
            try:
                await self._db.execute(statement)
            except Exception:
                # Already applied. Swallowed per column rather than per batch:
                # one loop with a 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.
                pass
        await self._db.execute(_REPLAY_INDEX)
        await self._db.commit()

    async def close(self) -> None:
        if self._db:
            await self._db.close()
            self._db = None

    async def __aenter__(self):
        await self.open()
        return self

    async def __aexit__(self, *_):
        await self.close()

    async def save_message(
        self,
        sender_id: str,
        iteration: int,
        payload: bytes,
        thread_id: str | None = None,
        sender_name: str = "",
        *,
        format: int = FORMAT_PLAIN,
        epoch: int = 0,
        device: bytes | None = None,
        nonce: bytes | None = None,
        sig: bytes | None = None,
    ) -> int:
        """
        Store a message. Returns the row id.

        Raises `ReplayedMessage` if this device has already used this nonce —
        see `_REPLAY_INDEX`. The caller must not turn that into a generic
        failure the sender retries: it means the message is already stored.
        """
        ts = time.time()
        try:
            cursor = await self._db.execute(
                "INSERT INTO messages (sender_id, iteration, payload, timestamp, "
                " thread_id, sender_name, format, epoch, device, nonce, sig) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                (sender_id, iteration, payload, ts, thread_id, sender_name,
                 format, epoch, device, nonce, sig),
            )
        except aiosqlite.IntegrityError as e:
            await self._db.rollback()
            raise ReplayedMessage(str(e)) from e
        await self._db.commit()
        return cursor.lastrowid

    async def get_messages(
        self,
        since: float = 0,
        limit: int = 100,
    ) -> list[StoredMessage]:
        """Get messages after a timestamp, most recent last."""
        cursor = await self._db.execute(
            f"SELECT {_COLUMNS} "
            "FROM messages WHERE timestamp > ? ORDER BY timestamp ASC LIMIT ?",
            (since, limit),
        )
        rows = await cursor.fetchall()
        return [_row(r) for r in rows]

    async def get_recent(self, limit: int = 100) -> list[StoredMessage]:
        """The newest `limit` messages, oldest first so they render in order.

        `get_messages(since=0, limit=n)` cannot answer this: it pages *forward*
        from the oldest, so a client opening a group with 300 messages was shown
        the first 200 and never the conversation it came for. Reaching the newest
        rows means ordering DESC in SQL and reversing here.
        """
        cursor = await self._db.execute(
            f"SELECT {_COLUMNS} "
            "FROM messages ORDER BY id DESC LIMIT ?",
            (limit,),
        )
        rows = await cursor.fetchall()
        return [_row(r) for r in reversed(rows)]

    async def get_before(self, before_id: int, limit: int = 50) -> list[StoredMessage]:
        """The `limit` messages immediately older than `before_id`, oldest first.

        The cursor is the row id, not the timestamp. Nothing makes `timestamp`
        unique — it is a float from `time.time()` — and a cursor on a value two
        rows can share is one that eventually skips a message or repeats it.
        `id` is AUTOINCREMENT: unique, and ordered by insertion.
        """
        cursor = await self._db.execute(
            f"SELECT {_COLUMNS} "
            "FROM messages WHERE id < ? ORDER BY id DESC LIMIT ?",
            (before_id, limit),
        )
        rows = await cursor.fetchall()
        return [_row(r) for r in reversed(rows)]

    async def has_before(self, message_id: int) -> bool:
        """Whether anything older than `message_id` exists — drives "load older"."""
        cursor = await self._db.execute(
            "SELECT 1 FROM messages WHERE id < ? LIMIT 1", (message_id,))
        return await cursor.fetchone() is not None

    async def get_thread(self, thread_id: str, limit: int = 100) -> list[StoredMessage]:
        """Get messages in a thread."""
        cursor = await self._db.execute(
            f"SELECT {_COLUMNS} "
            "FROM messages WHERE thread_id = ? ORDER BY timestamp ASC LIMIT ?",
            (thread_id, limit),
        )
        rows = await cursor.fetchall()
        return [_row(r) for r in rows]

    @property
    def db_path(self) -> Path:
        """Where this store lives — the re-encryption command backs it up."""
        return self._db_path

    async def count_by_format(self) -> tuple[int, int]:
        """(plaintext, sealed). What the operator is deciding from."""
        cursor = await self._db.execute(
            "SELECT format, COUNT(*) FROM messages GROUP BY format")
        counts = {row[0]: row[1] for row in await cursor.fetchall()}
        return (counts.get(FORMAT_PLAIN, 0), counts.get(FORMAT_SEALED_V1, 0))

    async def all_plaintext(self) -> list[StoredMessage]:
        """Every message still stored in the clear, oldest first."""
        cursor = await self._db.execute(
            f"SELECT {_COLUMNS} FROM messages WHERE format = ? ORDER BY id",
            (FORMAT_PLAIN,))
        return [_row(r) for r in await cursor.fetchall()]

    async def reseal(self, message_id: int, *, epoch: int, device: bytes,
                     nonce: bytes, ct: bytes, sig: bytes) -> None:
        """
        Replace one plaintext row with its sealed form. **Does not commit** —
        the caller commits once, so a re-encryption that fails half way leaves
        the database as it was rather than half readable.

        `sender_name` is cleared because it moves inside the envelope; leaving
        it would keep in the clear the one field the sealing was for.
        """
        await self._db.execute(
            "UPDATE messages SET format = ?, epoch = ?, device = ?, "
            " nonce = ?, payload = ?, sig = ?, sender_name = '' "
            "WHERE id = ?",
            (FORMAT_SEALED_V1, epoch, device, nonce, ct, sig, message_id))

    async def commit(self) -> None:
        await self._db.commit()

    async def delete_older_than(self, cutoff: float) -> int:
        """Retention. Returns how many rows went."""
        cursor = await self._db.execute(
            "DELETE FROM messages WHERE timestamp < ?", (cutoff,))
        await self._db.commit()
        return cursor.rowcount

    async def message_count(self) -> int:
        cursor = await self._db.execute("SELECT COUNT(*) FROM messages")
        row = await cursor.fetchone()
        return row[0]