diff options
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/__main__.py | 4 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/chat.py | 135 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/chat.py | 108 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/manpage.py | 14 |
4 files changed, 258 insertions, 3 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/__main__.py b/packages/meshbay-cli/src/meshbay_cli/__main__.py index dbb5a19..dd13d50 100644 --- a/packages/meshbay-cli/src/meshbay_cli/__main__.py +++ b/packages/meshbay-cli/src/meshbay_cli/__main__.py @@ -13,6 +13,7 @@ import sys from . import __version__ from .commands import ( browser_access, + chat, decline, devices, get, @@ -33,7 +34,7 @@ from .errors import INTERRUPTED, USAGE, CliError ACTIONS = {"login": login, "logout": logout, "groups": groups, "invitations": invitations, "join": join, "decline": decline, "leave": leave, "ls": ls, "search": search, "get": get, - "put": put, "rm": rm, "devices": devices, "trust": trust, + "put": put, "rm": rm, "chat": chat, "devices": devices, "trust": trust, "browser-access": browser_access} @@ -118,6 +119,7 @@ def main(argv: list[str] | None = None) -> int: return run() parser = build_parser() interactive = not argv + argv = chat.rewrite_count(argv, ACTIONS) try: args = parser.parse_args(argv) except SystemExit as e: # --help, --version, or a usage error 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") + diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py new file mode 100644 index 0000000..61e9fa7 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py @@ -0,0 +1,108 @@ +""" +`chat`: a group's conversation — the last messages, or one more posted. + +`chat GROUP` prints the last ten, oldest first; `-N` (as `head -N`) or `-n N` +asks for another count. `chat GROUP -m TEXT` posts TEXT (`-m -` reads it from +stdin), sealed and signed as the application seals and signs it. Each message +is shown with its time and the name its sender sealed in it; one that cannot +be read is shown as such, never left out. +""" + +import json +import re +import sys + +from ..client.chat import chat_keys, history, open_row, post +from ..client.groups import my_groups, open_group +from ..client.paths import group_arg, handle, resolve_group +from ..errors import USAGE, CliError +from ..output import date, err, safe, sentence +from .context import Context + +HELP = "read a group's chat (the last 10 messages, -N for N), or post with -m" + +_COUNT = re.compile(r"^-(\d+)$") + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("chat", parents=[parent], help=HELP, description=sentence(HELP) + + ". Messages are end-to-end encrypted and signed; the node " + "stores what it cannot read.") + p.add_argument("group", metavar="GROUP", help="the group, as `groups` lists it") + p.add_argument("-n", "--last", type=int, default=10, metavar="N", + help="how many of the last messages to show (also -N, as -100)") + p.add_argument("-m", "--message", metavar="TEXT", + help="post TEXT instead of reading; - reads it from stdin") + + +def rewrite_count(argv: list[str], actions) -> list[str]: + """`chat -100 GROUP` as `chat --last 100 GROUP`: argparse reads -100 as a + negative number, not as an option. Only when the action is `chat`.""" + action = next((i for i, a in enumerate(argv) if a in actions), None) + if action is None or argv[action] != "chat": + return argv + out = argv[:action + 1] + for a in argv[action + 1:]: + m = _COUNT.match(a) + out += ["--last", m.group(1)] if m else [a] + return out + + +def _text(message: str) -> str: + text = sys.stdin.read() if message == "-" else message + text = text.rstrip("\n") + if not text.strip(): + raise CliError(USAGE, "Nothing to post.") + return text + + +async def run(ctx: Context) -> int: + a = ctx.args + if a.last < 1: + raise CliError(USAGE, "Ask for one message at least.") + text = _text(a.message) if a.message is not None else None + await ctx.hub.check_version() + account = ctx.account() + session = None + try: + group = resolve_group(await my_groups(account), group_arg(a.group)) + session = await open_group(account, group) + keys = await chat_keys(session) + if text is not None: + await post(session, keys, text, account.username) + if a.json: + print(json.dumps({"posted": True, "group": handle(group)})) + elif not a.quiet: + err(f"Posted to {safe(handle(group))}.") + return 0 + messages = [open_row(r, keys, group["id"]) for r in await history(session, a.last)] + finally: + if session is not None: + await session.close() + await account.close() + + if a.json: + json.dump([{"id": m.id, "timestamp": m.timestamp, "sender": m.sender, + "text": m.text, **({"unreadable": m.unreadable} if m.unreadable + else {})} for m in messages], + sys.stdout, indent=1, ensure_ascii=False) + print() + return 0 + if not messages and not a.quiet: + err(f"No message in {safe(handle(group))} yet.") + for m in messages: + head = f"{date(int(m.timestamp))} {safe(m.sender)}: " + if m.unreadable: + print(f"{head}[this message cannot be read: {m.unreadable}]") + continue + lines = safe_lines(m.text) + print(head + lines[0]) + for line in lines[1:]: + print(" " * len(head) + line) + return 0 + + +def safe_lines(text: str) -> list[str]: + """A message's lines, each escaped: a line break stays a line break, every + other control character is shown, never sent to the terminal.""" + return [safe(line) for line in text.split("\n")] or [""] diff --git a/packages/meshbay-cli/src/meshbay_cli/manpage.py b/packages/meshbay-cli/src/meshbay_cli/manpage.py index 3fa9550..4491896 100644 --- a/packages/meshbay-cli/src/meshbay_cli/manpage.py +++ b/packages/meshbay-cli/src/meshbay_cli/manpage.py @@ -45,7 +45,8 @@ DESCRIPTION = r""" .B meshbay-cli is a member's view of their MeshBay groups from a terminal: sign in, join and leave groups, list them and their folders, search them all at once, download, -upload and delete files, and approve this account's other devices. +upload and delete files, read and post in a group's chat, and approve this +account's other devices. It talks to the hub only to sign in and to find a group's nodes; files go directly between this machine and the node, end-to-end encrypted, never through the hub. @@ -227,6 +228,13 @@ as it is when the owner hosts it with the desktop application. A run that names a file this account may not delete, or a folder, is refused before anything is asked or sent. .PP +.B chat +shows the last messages of a group, oldest first, each with its time and the name +its sender sealed in it, and posts one with +.BR \-m . +Messages are encrypted and signed end to end, as the application's are; one this +machine cannot verify or open is shown as unreadable, never left out. +.PP .B browser\-access on makes this machine leave, on each node it opens a group on, a copy of its keys sealed under your passphrase, so that a browser can open the group; @@ -301,6 +309,8 @@ meshbay\-cli ls \-\-json \-R / | jq '.[] | .path' meshbay\-cli search \-t audio holiday meshbay\-cli search beach 2024 | head \-1 | xargs \-d '\\n' meshbay\-cli get meshbay\-cli rm 'Family/documents/draft*.odt' +meshbay\-cli chat \-100 Family +echo 'On my way' | meshbay\-cli chat Family \-m \- meshbay\-cli devices approve Family@bob ZX4P\-7QRT meshbay\-cli leave \-\-yes Club@carol .EE @@ -400,7 +410,7 @@ def render() -> str: out += [".SH REMOTE PATHS", PATHS.strip(), ".SH INTERACTIVE MODE", INTERACTIVE.strip(), ".SH SESSION, DEVICES AND NODE KEYS", SESSION.strip(), - ".SH GROUPS: JOINING, LEAVING, DELETING, DEVICES", MEMBERSHIP.strip(), + ".SH GROUPS: JOINING, LEAVING, DELETING, CHAT, DEVICES", MEMBERSHIP.strip(), ".SH EXIT STATUS"] for code, text in sorted(EXIT_CODES.items()): out += [".TP", f".B {code}", text] |