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