diff options
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/chat.py | 135 |
1 files changed, 135 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/chat.py b/packages/meshbay-cli/src/meshbay_cli/client/chat.py new file mode 100644 index 0000000..bb0c83f --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/chat.py @@ -0,0 +1,135 @@ +""" +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. + +Not done here, and done by the application: checking that the signing device +is one the group's roster evidences for the account the node names (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 .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, ... + + +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:] + + +def open_row(row: dict, keys: ChatKeys, group_id: str) -> 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 + 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) + 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") + |