summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/chat.py
blob: 22a99efe9c164729af94823a6bfcb2056c9b8788 (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
"""
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.
"""

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 = ""


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 = 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


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, "thread_id": None, "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"],
                              "thread_id": None, "sender_name": None}, "ack")