summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/chat.py
blob: d525abf868846a1c5e24da3f7b8a86ce11b8712f (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
"""
A group's chat: reading its history, and posting (docs/MESHBAY_DESIGN.md §4.5).

Every message is sealed under the group's current chat epoch key, through a
subkey derived for the sending device, and signed by that device's key on the
node — `meshbay_common.chatbox`, the same construction the application uses
(`crypto.js` `sealChat`, held to it by the parity tests). The node relays and
stores ciphertext; it reads nothing.

Reading is done the way the application reads: authorship first — the
signature is over the ciphertext, so a message that does not verify is never
shown as written by anyone — then the epoch key, then the content. A message
that does not open is kept and marked, never dropped: a gap the reader can see
is honest, a conversation quietly missing a message is not.

Then whose device signed it: the node names an account (`sender_id`), and
`roster.RosterCheck` answers whether the signing device is one this machine
accepted for that account, or one countersigned into it since (Tier 2, design
§3.3). The name shown is the one sealed in the message by its sender.

A file sent in the chat is two things, as the application sends it: the file,
uploaded into the chat's folder, then a message whose text is
`{"text": "", "attachment": {filename, size, type}}` naming it there.
"""

import json
import time
from dataclasses import dataclass

from meshbay_common import MNP_VERSION
from meshbay_common.chatbox import open_message, seal, verify
from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, unseal

from ..errors import NODE, USAGE, CliError
from .keys import b64, unb64
from .roster import RosterCheck
from .session import GroupSession

# The node answers at most this many messages per request.
PAGE = 200
# Sealed messages only: MNP 2.0 has no plaintext chat.
FORMAT_SEALED = 1
# The node's bound on one message's ciphertext (`MAX_CHAT_CIPHERTEXT`): checked
# on the sealed bytes, so nothing is sent that it would refuse.
MAX_CIPHERTEXT = 64 * 1024


@dataclass
class Message:
    id: int
    timestamp: float
    sender: str            # the name sealed by its sender, else the node's
    text: str
    unreadable: str = ""   # why it could not be read: signature, epoch, ...
    trust: str = ""        # roster.RosterCheck's verdict on the signing device
    # Which message this is, wherever it was read from — history or a push,
    # which carries no id: the node stores a (device, nonce) pair only once.
    key: str = ""
    # A file sent in the chat: {filename, size, type}, in the chat's folder.
    attachment: dict | None = None


class ChatKeys:
    """Every epoch key the node gives this member, and the current epoch."""

    def __init__(self, by_epoch: dict[int, bytes], current: int):
        self.by_epoch = by_epoch
        self.current = current


async def chat_keys(session: GroupSession) -> ChatKeys:
    resp = await session.ch.request({"type": "chat_keys_req", "v": MNP_VERSION,
                                     "group_id": session.group_id}, "chat_keys_resp")
    try:
        payload = unseal(session.gek, PURPOSE_CHAT_KEYS, "chat_keys_resp",
                         session.group_id, resp)
    except Exception as e:
        # Not "no keys": a reply that does not open is a peer this client cannot
        # talk to, and reading it as an empty set would show an empty chat.
        raise CliError(NODE, "The chat keys do not open under the group key.") from e
    by_epoch = {int(e["epoch"]): bytes(e["key"]) for e in payload.get("epochs") or []}
    return ChatKeys(by_epoch, int(payload.get("current") or 0))


async def history(session: GroupSession, count: int) -> list[dict]:
    """The last `count` stored messages, oldest first, paging back as needed."""
    rows: list[dict] = []
    before = None
    while len(rows) < count:
        page = await session.ch.request(
            {"type": "chat_hist", "v": MNP_VERSION, "before": before,
             "limit": min(PAGE, count - len(rows))}, "chat_hist_resp")
        got = page.get("messages") or []
        rows = got + rows
        if not got or not page.get("has_more"):
            break
        before = got[0]["id"]
    return rows[-count:]


async def open_row(row: dict, keys: ChatKeys, group_id: str,
                   check: RosterCheck | None = None) -> Message:
    sender = str(row.get("sender_name") or row.get("sender_id") or "")
    msg = Message(int(row.get("id") or 0), float(row.get("timestamp") or 0), sender, "")
    if (row.get("format") or 0) != FORMAT_SEALED:
        msg.unreadable = "not sealed"
        return msg
    epoch = int(row.get("epoch") or 0)
    device, nonce, ct, sig = (row.get(k) for k in ("device", "nonce", "ct", "sig"))
    if not (device and nonce and ct and sig):
        msg.unreadable = "envelope"
        return msg
    msg.key = f"{b64(bytes(device))}:{b64(bytes(nonce))}"
    if not verify(group_id, epoch, bytes(device), bytes(nonce), bytes(ct), bytes(sig)):
        msg.unreadable = "signature"
        return msg
    key = keys.by_epoch.get(epoch)
    if key is None:
        msg.unreadable = "no key for its epoch"
        return msg
    try:
        payload = open_message(key, group_id, epoch, b64(bytes(device)), nonce, ct)
    except Exception:
        msg.unreadable = "does not decrypt"
        return msg
    msg.text, msg.attachment = read_attachment(str(payload.get("text") or ""))
    msg.sender = str(payload.get("sender_name") or sender)
    if check is not None:
        msg.trust = await check.status(str(row.get("sender_id") or ""),
                                       b64(bytes(device)))
    return msg


def read_attachment(text: str) -> tuple[str, dict | None]:
    """A message's text and the file it names, if it is the application's
    attachment message; anything else is text as it is."""
    if not text.startswith("{"):
        return text, None
    try:
        parsed = json.loads(text)
    except ValueError:
        return text, None
    att = parsed.get("attachment") if isinstance(parsed, dict) else None
    if not (isinstance(att, dict) and isinstance(att.get("filename"), str)
            and att["filename"]):
        return text, None
    size = att.get("size")
    return (str(parsed.get("text") or ""),
            {"filename": att["filename"],
             "size": size if isinstance(size, int) and size >= 0 else 0,
             "type": str(att.get("type") or "file")})


IMAGE_EXT = {"jpg", "jpeg", "png", "gif", "webp", "svg"}
VIDEO_EXT = {"mp4", "webm", "mkv", "mov", "avi"}


def attachment_text(stored_as: str, size: int) -> str:
    """The message that shows `stored_as` as a file in the application's chat."""
    ext = stored_as.rsplit(".", 1)[-1].lower() if "." in stored_as else ""
    kind = "image" if ext in IMAGE_EXT else "video" if ext in VIDEO_EXT else "file"
    return json.dumps({"text": "", "attachment": {"filename": stored_as, "size": size,
                                                  "type": kind}})


def chat_folder(ack: dict, roots: dict[str, dict]) -> str:
    """
    Where the chat's files are: the folder the operator chose for it, while its
    root is writable and present, else the first such root — the application's
    rule (group-page.js `attachDir`/`attachRoot`). Empty: nowhere to write.
    """
    usable = [name for name, r in roots.items()
              if r.get("writable") and r.get("available", True)]
    chosen = str(ack.get("chat_directory") or "").strip("/")
    if chosen and chosen.split("/", 1)[0] in usable:
        return chosen
    return usable[0] if usable else ""


async def post(session: GroupSession, keys: ChatKeys, text: str, sender_name: str) -> None:
    """Seal, sign and send one message under the current epoch."""
    epoch = int(session.ack.get("chat_epoch") or keys.current)
    key = keys.by_epoch.get(epoch)
    if key is None:
        raise CliError(NODE, "No chat key for this group's current epoch; try again.")
    ident = session.identity
    device_b64 = ident.pk_ed
    sealed = seal(key, session.group_id, epoch, device_b64, unb64(device_b64), ident.sk_ed,
                  {"text": text, "sender_name": sender_name,
                   "sent_at": int(time.time())})
    if len(sealed["ct"]) > MAX_CIPHERTEXT:
        raise CliError(USAGE, "This message is too large for chat; send it as a file "
                              "with `put` instead.")
    await session.ch.request({"type": "chat_msg", "v": MNP_VERSION,
                              "format": FORMAT_SEALED, "epoch": epoch,
                              "ct": sealed["ct"], "nonce": sealed["nonce"],
                              "device": unb64(device_b64), "sig": sealed["sig"],
                              "sender_name": None}, "ack")