aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/chat.py135
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")
+