aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ops/chat.py
blob: 469e907e83cee1cdad381561a9084ae55c7282ab (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
"""A group's chat keys (epochs) and its stored history."""

from __future__ import annotations

import logging
import time as _time

from meshbay_common.chatbox import new_epoch_key
from meshbay_common.crypto import unwrap_gek_aes, wrap_gek_aes

from meshbay_node.ops.core import OpError, _group_ctx

log = logging.getLogger("meshbay_node.ops")


# ── Chat epoch keys ──────────────────────────────────────────────────────────
#
# The key a group's chat archive is encrypted under. Generated here, by the
# node, and never by a member — the C5b rule is about key material arriving from
# outside, and this is the same rule that lets `gek_rotate` be a signed
# instruction rather than a delivery.
#
# An *epoch* rather than a rotation, and the distinction is the whole design:
# opening a new one stops a departing member reading what comes next, while
# every earlier epoch is kept and still delivered to current members, so the
# history they could already read stays readable. Rotating instead — replacing
# the key, as `set_gek` does — would make every message anyone ever sent
# permanently unreadable to everybody, which is what a plain GEK-derived
# archive key would have done on the very first `member unpin`
# (finding F4, docs/MESHBAY_DESIGN.md §13.6).


async def _wrap_for_node(state: dict, key: bytes) -> dict:
    """
    Wrap a key to the node's own X25519 key, the way `set_gek` does for the GEK.

    Wrapped, not raw: the claim chat encryption makes is against someone who
    obtains the node's storage *without the keystore password*, and the node's
    X25519 private key is what the keystore protects. A raw key in SQLite would
    leave nothing behind that claim.
    """
    pk_x_node_raw = state.get("pk_x25519_raw")
    if not pk_x_node_raw:
        raise OpError("Node identity not available", status=503)
    return wrap_gek_aes(key, pk_x_node_raw)


async def chat_epoch_keys(state: dict, group_id: str) -> list[dict]:
    """
    Every chat epoch key this group has, oldest first, in the clear *in memory*.

    Cached on the group context: unwrapping is an ECIES operation per epoch and
    this is on the path of every member connecting to a group with chat on.
    """
    ctx = _group_ctx(state, group_id)
    cached = ctx.get("chat_epoch_keys")
    if cached is not None:
        return cached

    bundle_store = state.get("bundle_store")
    if not bundle_store:
        raise OpError("Bundle store not available", status=503)
    sk_x_raw = state.get("sk_x25519_raw")
    pk_x_raw = state.get("pk_x25519_raw")
    if not (sk_x_raw and pk_x_raw):
        raise OpError("Node identity not available", status=503)

    keys: list[dict] = []
    for row in await bundle_store.fetch_chat_epochs(group_id):
        try:
            keys.append({"epoch": row["epoch"],
                         "key": unwrap_gek_aes(row, sk_x_raw, pk_x_raw)})
        except Exception as e:
            # Loud, and not fatal: one unreadable epoch must not take the
            # readable ones with it. The messages of that epoch are lost, which
            # is a thing the operator needs told rather than a thing to hide.
            log.error("chat: epoch %d of group %s will not unwrap (%s) — "
                      "its messages are unreadable", row["epoch"],
                      group_id[:8], e)
    ctx["chat_epoch_keys"] = keys
    return keys


async def open_chat_epoch(state: dict, group_id: str) -> dict:
    """
    Open a new chat epoch. Idempotent only in the sense that it always adds one.

    Called when the set of devices that may read *future* messages shrinks: a
    member removed, a device revoked or unpinned, the group key rotated, or the
    operator asking directly. Never on a schedule — an epoch nobody needed is an
    epoch key the node has to keep for ever.
    """
    bundle_store = state.get("bundle_store")
    if not bundle_store:
        raise OpError("Bundle store not available", status=503)

    epoch = await bundle_store.latest_chat_epoch(group_id) + 1
    key = new_epoch_key()
    wrapped = await _wrap_for_node(state, key)
    await bundle_store.store_chat_epoch(
        group_id, epoch, wrapped["pk_eph_b64"], wrapped["nonce_b64"],
        wrapped["wrapped_b64"])

    # Tolerant of a group context that does not exist yet: the daemon opens the
    # first epoch **while it is building** `groups_ctx`, before publishing it on
    # the state, because a group with no epoch key is a group nobody can speak
    # in. Insisting on the context here would make start-up the one moment this
    # cannot be called.
    ctx = (state.get("groups_ctx") or {}).get(group_id)
    if ctx is not None:
        cached = ctx.get("chat_epoch_keys")
        if cached is not None:
            cached.append({"epoch": epoch, "key": key})
        ctx["chat_epoch"] = epoch

    # The transports hold their own view of the group, exactly as `set_gek`
    # notes: an epoch that did not reach them would have members sealing under
    # a key the node no longer thinks is current.
    for transport_key in ("webrtc", "quic_server"):
        transport = state.get(transport_key)
        groups = getattr(transport, "_ctx", {}).get("groups") if transport else None
        if groups and group_id in groups:
            groups[group_id]["chat_epoch"] = epoch
            groups[group_id].pop("chat_epoch_keys", None)

    log.info("Chat epoch %d opened for group %s", epoch, group_id[:8])
    return {"epoch": epoch}


async def ensure_chat_epoch(state: dict, group_id: str) -> int:
    """The current epoch, opening the first one if the group has none."""
    bundle_store = state.get("bundle_store")
    if not bundle_store:
        raise OpError("Bundle store not available", status=503)
    epoch = await bundle_store.latest_chat_epoch(group_id)
    if epoch:
        return epoch
    return (await open_chat_epoch(state, group_id))["epoch"]


async def chat_status(state: dict, group_id: str) -> dict:
    """What the operator needs to decide anything about this group's chat."""
    ctx = _group_ctx(state, group_id)
    bundle_store = state.get("bundle_store")
    store = ctx.get("chat_store")
    plain = sealed = 0
    if store is not None:
        plain, sealed = await store.count_by_format()
    return {
        "group_id": group_id,
        "epoch": (await bundle_store.latest_chat_epoch(group_id)
                  if bundle_store else 0),
        # Rows written before MNP 2.0. Not a state the node can be *in* — chat
        # is always encrypted now — but a state its disk can be in until
        # `chat encrypt-history` has run, and the operator has to be told,
        # because those messages are the ones still readable off a stolen disk.
        "plaintext_messages": plain,
        "encrypted_messages": sealed,
    }


async def encrypt_chat_history(state: dict, group_id: str) -> dict:
    """
    Re-encrypt the messages written before this group turned encryption on.

    Deliberately **not** done by the switch. It rewrites the only copy of a
    conversation, and a toggle that does that is one somebody flips twice; this
    is an explicit command, it copies the database first, and it runs in one
    transaction.

    The node can do this at all only because it holds those rows in plaintext —
    it is the last moment at which anyone can. Afterwards nothing on this
    machine can read them without an epoch key.

    Messages are sealed under a **synthetic device** belonging to the node, not
    under the original sender's key: the node does not hold anyone's signing key
    and must not pretend to. They are marked as such, so a reader is told these
    carry the node's word for who wrote them — which is all they ever carried,
    since they were written before signing existed.
    """
    import shutil

    from meshbay_common.chatbox import seal

    ctx = _group_ctx(state, group_id)
    store = ctx.get("chat_store")
    if store is None:
        raise OpError("This group has no chat store", status=404)

    epoch = await ensure_chat_epoch(state, group_id)
    keys = {k["epoch"]: k["key"] for k in await chat_epoch_keys(state, group_id)}
    key = keys.get(epoch)
    if not key:
        raise OpError("No chat key for this group", status=503)

    sk_node = state.get("sk_node")
    if sk_node is None:
        raise OpError("Node identity not available", status=503)
    from cryptography.hazmat.primitives import serialization

    device_raw = sk_node.public_key().public_bytes(
        serialization.Encoding.Raw, serialization.PublicFormat.Raw)
    import base64 as _b64

    device_b64 = _b64.b64encode(device_raw).decode()

    backup = store.db_path.with_name(
        f"{store.db_path.name}.bak-{int(_time.time())}")
    shutil.copy2(store.db_path, backup)

    converted = 0
    for row in await store.all_plaintext():
        text = (row.payload.decode("utf-8", errors="replace")
                if isinstance(row.payload, bytes) else str(row.payload))
        env = seal(key, group_id, epoch, device_b64, device_raw, sk_node, {
            "text": text,
            "thread_id": row.thread_id,
            "sender_name": row.sender_name,
            "sent_at": int(row.timestamp),
            # The node sealed this after the fact; it did not witness it being
            # signed. Said in the payload rather than inferred from the device.
            "migrated": True,
        })
        await store.reseal(row.id, epoch=epoch, device=device_raw,
                           nonce=env["nonce"], ct=env["ct"], sig=env["sig"])
        converted += 1
    await store.commit()

    log.info("Chat history re-encrypted for group %s: %d message(s), backup %s",
             group_id[:8], converted, backup.name)
    return {"group_id": group_id, "converted": converted,
            "backup": str(backup), "epoch": epoch}


async def prune_chat(state: dict, group_id: str, max_age_days: int) -> dict:
    """
    Delete messages older than `max_age_days`. Epoch keys are never touched.

    An epoch whose messages have all aged out costs 32 bytes and keeps the
    operation reversible in the only direction that matters: nothing that is
    still stored becomes unreadable because something else was deleted.
    """
    ctx = _group_ctx(state, group_id)
    store = ctx.get("chat_store")
    if store is None:
        raise OpError("This group has no chat store", status=404)
    if max_age_days < 1:
        raise OpError("max_age_days must be at least 1", status=400)
    removed = await store.delete_older_than(
        _time.time() - max_age_days * 86400)
    log.info("Chat retention for group %s: %d message(s) removed",
             group_id[:8], removed)
    return {"group_id": group_id, "removed": removed,
            "max_age_days": max_age_days}