diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/account.py | 14 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/chat.py | 15 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/roster.py | 152 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/chat.py | 36 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/manpage.py | 11 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/tui/app.py | 15 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/tui/chat.py | 246 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/fake_node.py | 51 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_e2e_local.py | 51 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_interactive.py | 79 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_roster.py | 100 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_account_pinning.py | 32 |
12 files changed, 782 insertions, 20 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/account.py b/packages/meshbay-cli/src/meshbay_cli/client/account.py index 9bb85ce..96483e9 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/account.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/account.py @@ -162,6 +162,8 @@ class Account: "known_browser": r.get("known_browser") or previous.get("known_browser"), # A node's key does not depend on who signed in. "pins": previous.get("pins", {}) if previous.get("hub") == self.hub.url else {}, + "account_pins": (previous.get("account_pins", {}) + if previous.get("hub") == self.hub.url else {}), } if same_account and previous.get("browser_access"): state["browser_access"] = previous["browser_access"] @@ -341,6 +343,18 @@ class Account: def pepper_version(self) -> int: return int(self.secrets.get("pepper_version") or 1) + # The device keys this machine accepted for each account, per node (Tier 2, + # `roster.py`): public keys, kept with the other pins. Losing them costs one + # "first sight", never a wrong answer. + + def account_pins(self, node_pk: str, user_id: str) -> list[str] | None: + return self.state.get("account_pins", {}).get(node_pk, {}).get(user_id) + + async def set_account_pins(self, node_pk: str, user_id: str, keys: list[str]) -> None: + def put(state: dict, secrets: dict) -> None: + state.setdefault("account_pins", {}).setdefault(node_pk, {})[user_id] = keys + await self.update(put) + def pin(self, node_id: str) -> str | None: return self.state.get("pins", {}).get(node_id) diff --git a/packages/meshbay-cli/src/meshbay_cli/client/chat.py b/packages/meshbay-cli/src/meshbay_cli/client/chat.py index bb0c83f..60629e2 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/chat.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/chat.py @@ -13,9 +13,10 @@ 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. +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 @@ -27,6 +28,7 @@ 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. @@ -45,6 +47,7 @@ class Message: 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 class ChatKeys: @@ -85,7 +88,8 @@ async def history(session: GroupSession, count: int) -> list[dict]: return rows[-count:] -def open_row(row: dict, keys: ChatKeys, group_id: str) -> Message: +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: @@ -110,6 +114,9 @@ def open_row(row: dict, keys: ChatKeys, group_id: str) -> Message: 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 diff --git a/packages/meshbay-cli/src/meshbay_cli/client/roster.py b/packages/meshbay-cli/src/meshbay_cli/client/roster.py new file mode 100644 index 0000000..cce5a14 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/roster.py @@ -0,0 +1,152 @@ +""" +Who signed a chat message, checked rather than taken on the node's word: Tier 2 +of docs/MESHBAY_DESIGN.md §3.3, as `static/transport-roster.js` does it. + +A message's signature proves *a device* wrote it. Whether that device belongs +to the account the node names is answered here, from two things: the group's +roster — each device with the already-pinned key that countersigned it and the +signature, relayed by the node, which asserts nothing — and the keys this +machine accepted for each account on each node before (kept in the store). + +The property, exactly: once this machine has seen an account, a node that later +substitutes a key for it is detected. Nothing is gained at first sight, where +there is nothing to compare against. + + pinned seen before, the same key — nothing to say + linked new, countersigned by a key already pinned for the account + first first sight of the account: trust on first use + changed a key this account has not shown before and nothing evidences + unknown the roster could not be read +""" + +import base64 +from dataclasses import dataclass, field + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from meshbay_common import MNP_VERSION +from meshbay_common.device import device_add_transcript +from meshbay_common.groupbox import PURPOSE_ROSTER, unseal + +from .account import Account +from .session import GroupSession + + +@dataclass +class AccountDevices: + username: str + all: list[str] + verified: list[str] + chain: dict[str, str] = field(default_factory=dict) # device → its countersigner + unevidenced: list[str] = field(default_factory=list) + + +def _signed(pk_b64: str, sig_b64, transcript: bytes) -> bool: + try: + sig = base64.b64decode(sig_b64) if isinstance(sig_b64, str) else bytes(sig_b64) + Ed25519PublicKey.from_public_bytes(base64.b64decode(pk_b64)).verify(sig, transcript) + return True + except Exception: + return False + + +def verify_roster(payload: dict, node_pk: str) -> dict[str, AccountDevices]: + """ + Each account's devices, walked outwards from its roots. + + A root names **no** countersigner: an operator code admitted it. One that + names a countersigner and carries no proof is not a root but unevidenced — + a node writing the roster could otherwise launder any key in by mentioning + it, which is also the honest reading of a device pinned before the evidence + was kept. + """ + per: dict[str, list[dict]] = {} + for d in payload.get("devices") or []: + per.setdefault(d["user_id"], []).append(d) + node = payload.get("node_pk") or node_pk + out = {} + for user_id, rows in per.items(): + verified = [d["pk_ed25519"] for d in rows if not d.get("added_by_pk")] + pending = [d for d in rows if d.get("added_by_pk")] + chain: dict[str, str] = {} + progress = True + while progress and pending: + progress = False + for d in list(pending): + if d["added_by_pk"] not in verified: + continue + try: + nonce = base64.b64decode(d.get("add_nonce") or "") + transcript = device_add_transcript( + node_pk_b64=node, user_id=user_id, pk_ed25519_b64=d["pk_ed25519"], + pk_x25519_b64=d["pk_x25519"], nonce_node=nonce, + ts=int(d.get("add_ts") or 0)) + except Exception: + continue + if _signed(d["added_by_pk"], d.get("add_sig") or "", transcript): + verified.append(d["pk_ed25519"]) + chain[d["pk_ed25519"]] = d["added_by_pk"] + pending.remove(d) + progress = True + out[user_id] = AccountDevices(rows[0].get("username") or "", + [d["pk_ed25519"] for d in rows], verified, chain, + [d["pk_ed25519"] for d in pending]) + return out + + +def judge(known: list[str] | None, entry: AccountDevices | None, + device: str) -> tuple[str, list[str] | None]: + """ + The status of `device` for an account, and the keys to keep if it stands. + Decides and writes nothing, so a verdict on a stale roster can be taken + again on a fresh one without half a set pinned first. + """ + if known and device in known: + return "pinned", None + if not known: + # First sight pins everything the node says, not the verified subset: an + # alarm on a legitimate second device whose evidence predates it being + # kept is an alarm nobody reads. + pin = entry.all if entry else None + return ("first" if entry and device in entry.all else "changed"), pin + if entry and device in entry.verified and entry.chain.get(device) in known: + return "linked", sorted(set(known) | {device}) + return "changed", None + + +class RosterCheck: + """One connection's roster, read once, and read again once per account and + device before "changed" is said — a member who joined since it was read is + otherwise taken for a substitution.""" + + def __init__(self, account: Account, session: GroupSession): + self.account = account + self.session = session + self._roster: dict[str, AccountDevices] | None = None + self._rechecked: set[tuple[str, str]] = set() + + async def roster(self, *, fresh: bool = False) -> dict[str, AccountDevices]: + if self._roster is None or fresh: + s = self.session + resp = await s.ch.request({"type": "group_roster_req", "v": MNP_VERSION, + "group_id": s.group_id}, "group_roster_resp") + payload = unseal(s.gek, PURPOSE_ROSTER, "group_roster_resp", s.group_id, resp) + self._roster = verify_roster(payload, s.node_pk) + return self._roster + + async def status(self, user_id: str, device: str) -> str: + try: + roster = await self.roster() + except Exception: + return "unknown" + known = self.account.account_pins(self.session.node_pk, user_id) + verdict, pin = judge(known, roster.get(user_id), device) + if verdict == "changed" and (user_id, device) not in self._rechecked: + self._rechecked.add((user_id, device)) + try: + roster = await self.roster(fresh=True) + verdict, pin = judge(known, roster.get(user_id), device) + except Exception: + pass # the verdict on the copy we had stands + if pin is not None: + await self.account.set_account_pins(self.session.node_pk, user_id, pin) + return verdict diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py index 61e9fa7..6fa084d 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py @@ -1,7 +1,9 @@ """ `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` +`chat` alone opens the interactive chat (`tui/chat.py`): the groups, one +group's conversation IRC-style, back to the groups. `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 @@ -15,20 +17,27 @@ 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 ..client.roster import RosterCheck 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" +HELP = ("chat: alone, interactively; with a group, its last 10 messages (-N for N), " + "or post with -m") _COUNT = re.compile(r"^-(\d+)$") +# The one verdict worth a line (roster.py): the application's words. +KEY_CHANGED = ("! This account is using a key you have not seen before: a new device " + "added elsewhere, or the node naming the wrong person.") 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("group", metavar="GROUP", nargs="?", + help="the group, as `groups` lists it; without it, the interactive " + "chat: choose a group, talk, leave it, choose another") 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", @@ -60,6 +69,16 @@ async def run(ctx: Context) -> int: a = ctx.args if a.last < 1: raise CliError(USAGE, "Ask for one message at least.") + if a.group is None: + if a.message is not None: + raise CliError(USAGE, "Name the group to post to.") + if not (sys.stdin.isatty() and sys.stdout.isatty()): + raise CliError(USAGE, "The interactive chat needs a terminal; name a group " + "to read it, or to post with -m.") + from ..tui.app import run_interactive + from ..tui.chat import ChatGroupsScreen + # As with no argument at all: the sign-in screen checks the hub. + return await run_interactive(ctx, ChatGroupsScreen) text = _text(a.message) if a.message is not None else None await ctx.hub.check_version() account = ctx.account() @@ -75,7 +94,9 @@ async def run(ctx: Context) -> int: 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)] + check = RosterCheck(account, session) + messages = [await open_row(r, keys, group["id"], check) + for r in await history(session, a.last)] finally: if session is not None: await session.close() @@ -83,8 +104,9 @@ async def run(ctx: Context) -> int: 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], + "text": m.text, "trust": m.trust, + **({"unreadable": m.unreadable} if m.unreadable else {})} + for m in messages], sys.stdout, indent=1, ensure_ascii=False) print() return 0 @@ -99,6 +121,8 @@ async def run(ctx: Context) -> int: print(head + lines[0]) for line in lines[1:]: print(" " * len(head) + line) + if m.trust == "changed": + print(" " * len(head) + KEY_CHANGED) return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/manpage.py b/packages/meshbay-cli/src/meshbay_cli/manpage.py index 4491896..022cd99 100644 --- a/packages/meshbay-cli/src/meshbay_cli/manpage.py +++ b/packages/meshbay-cli/src/meshbay_cli/manpage.py @@ -234,6 +234,17 @@ 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. +The device that signed a message is checked against the group's roster and the +keys this machine accepted for that account before; a key it has not seen and +nothing vouches for is pointed out under the message. +.B meshbay\-cli chat +with no group opens the interactive chat: the groups (Enter joins one whose chat +is on), then the conversation, with new messages as they arrive and a line to +write in; Esc or +.B /part +goes back to the groups, +.B /quit +or Ctrl+Q quits. .PP .B browser\-access on makes this machine leave, on each node it opens a group on, a copy of its keys diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/app.py b/packages/meshbay-cli/src/meshbay_cli/tui/app.py index dbb773c..fe7fa2c 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/app.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/app.py @@ -3,7 +3,8 @@ Textual, because it runs on the same asyncio loop as aiortc and httpx rather than beside them, and on Linux terminals and Windows' consoles alike. Signed in -already, it opens on the two panes; otherwise on the sign-in screen first. +already, it opens on the two panes — or, for `meshbay-cli chat` with no group, +on the groups to chat in (`tui/chat.py`); otherwise on the sign-in screen first. Nothing is printed while it runs — a line on stderr would tear the screen — so the log goes to a file in the CLI's data directory instead. @@ -24,10 +25,12 @@ from .screens import DeviceCode, MainScreen, SignInScreen class MeshBayApp(App): TITLE = "MeshBay" - def __init__(self, ctx: Context): + def __init__(self, ctx: Context, home=MainScreen): super().__init__() self.ctx = ctx self.hub = ctx.hub + # The screen a signed-in account lands on, made from the account. + self.home = home async def use_hub(self, url: str) -> None: if url != self.hub.url: @@ -51,9 +54,9 @@ class MeshBayApp(App): def start(self, account: Account) -> None: account.on_device_code = DeviceCode(self) if isinstance(self.screen, SignInScreen): - self.switch_screen(MainScreen(account)) + self.switch_screen(self.home(account)) else: - self.push_screen(MainScreen(account)) + self.push_screen(self.home(account)) def _log_to_file() -> None: @@ -70,7 +73,7 @@ def _log_to_file() -> None: root.addHandler(handler) -async def run_interactive(ctx: Context) -> int: +async def run_interactive(ctx: Context, home=MainScreen) -> int: _log_to_file() - await MeshBayApp(ctx).run_async() + await MeshBayApp(ctx, home).run_async() return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py new file mode 100644 index 0000000..83c1753 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py @@ -0,0 +1,246 @@ +""" +`meshbay-cli chat` with no group: the groups, then one group's chat, IRC-style. + +The list shows every group of the account; Enter opens one whose node is up and +whose chat is on (`enabled_apps` in the sealed ack, empty meaning all). The room +shows the last messages, then the ones that arrive (`chat_msg` pushed by the +node), and a line at the bottom to write in. Esc, or `/part`, goes back to the +list; Ctrl+Q, or `/quit`, quits. Messages are read and posted exactly as the +`chat` action reads and posts them — signature, epoch key, content, then the +roster's verdict on the signing device — and drawn as text, never markup. +""" + +import asyncio +import contextlib +import time + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.screen import Screen +from textual.widgets import DataTable, Footer, Input, RichLog, Static + +from ..client.account import Account +from ..client.chat import Message, chat_keys, history, open_row, post +from ..client.groups import my_groups +from ..client.paths import handle +from ..client.pool import Pool +from ..client.roster import RosterCheck +from ..errors import CliError +from ..output import safe + +# How much of the conversation a room opens on. +BACKLOG = 50 +HELP = "/part or Esc: back to the groups /quit or Ctrl+Q: quit /help: this" +KEY_CHANGED = ("! This account is using a key you have not seen before: a new device " + "added elsewhere, or the node naming the wrong person.") + + +def chat_enabled(ack: dict) -> bool: + apps = ack.get("enabled_apps") or [] + return not apps or "chat" in apps + + +class ChatGroupsScreen(Screen): + BINDINGS = [ + Binding("r", "refresh", "Refresh"), + Binding("q", "app.quit", "Quit"), + ] + + DEFAULT_CSS = """ + ChatGroupsScreen > .title { height: 1; padding: 0 1; background: $panel; } + ChatGroupsScreen > DataTable { height: 1fr; } + """ + + def __init__(self, account: Account): + super().__init__() + self.account = account + self.pool = Pool(account) + self.groups: list[dict] = [] + + def compose(self) -> ComposeResult: + yield Static(Text("Chat — choose a group, Enter to join"), classes="title") + table = DataTable(cursor_type="row") + table.add_column("Group", key="group") + table.add_column("Node", key="node") + yield table + yield Footer() + + def on_mount(self) -> None: + self.query_one(DataTable).focus() + self.action_refresh() + + def action_refresh(self) -> None: + self.run_worker(self._load(), exclusive=True, group="groups") + + async def _load(self) -> None: + try: + self.groups = sorted(await my_groups(self.account), + key=lambda g: (g["name"].lower(), g.get("owner_username"))) + except CliError as e: + self.app.notify(e.message, severity="error", markup=False) + return + table = self.query_one(DataTable) + table.clear() + for g in self.groups: + up = g.get("node_online") + table.add_row(Text(safe(handle(g)), style="" if up else "dim", no_wrap=True, + overflow="ellipsis"), + Text("up" if up else "down", style="" if up else "dim"), + key=g["id"]) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + group = next((g for g in self.groups if g["id"] == event.row_key.value), None) + if group is not None: + self.run_worker(self._join(group), exclusive=True, group="join") + + async def _join(self, group: dict) -> None: + self.app.notify(f"Joining {safe(handle(group))}…", timeout=3, markup=False) + try: + session = await self.pool.session(group) + except CliError as e: + self.app.notify(e.message, severity="error", markup=False, timeout=8) + return + if not chat_enabled(session.ack): + self.app.notify(f"{safe(handle(group))} has its chat turned off.", + severity="warning", markup=False) + return + await self.app.push_screen(ChatRoomScreen(self.account, group, session)) + + async def on_unmount(self) -> None: + with contextlib.suppress(Exception): + await self.pool.close() + + +class ChatRoomScreen(Screen): + BINDINGS = [Binding("escape", "part", "Back to the groups")] + + DEFAULT_CSS = """ + ChatRoomScreen > .title { height: 1; padding: 0 1; background: $panel; } + ChatRoomScreen > RichLog { height: 1fr; padding: 0 1; } + ChatRoomScreen > Input { dock: bottom; } + """ + + def __init__(self, account: Account, group: dict, session): + super().__init__() + self.account = account + self.group = group + self.session = session + self.keys = None + self.check = RosterCheck(account, session) + self._day = "" + self._ready = asyncio.Event() + + def compose(self) -> ComposeResult: + yield Static(Text(f"#{safe(handle(self.group))} — {HELP}", no_wrap=True, + overflow="ellipsis"), classes="title") + yield RichLog(wrap=True, markup=False, highlight=False, auto_scroll=True) + yield Input(placeholder=f"Message to {safe(handle(self.group))}") + yield Footer() + + @property + def log_view(self) -> RichLog: + return self.query_one(RichLog) + + def on_mount(self) -> None: + self.query_one(Input).focus() + self.session.ch.on_push("chat_msg", self._pushed) + self.session.ch.on_push("chat_epoch_ack", self._epoch_moved) + self.run_worker(self._open(), group="room") + + def on_unmount(self) -> None: + self.session.ch.off_push("chat_msg", self._pushed) + self.session.ch.off_push("chat_epoch_ack", self._epoch_moved) + + async def _open(self) -> None: + try: + self.keys = await chat_keys(self.session) + rows = await history(self.session, BACKLOG) + for row in rows: + self.show(await open_row(row, self.keys, self.group["id"], self.check)) + except CliError as e: + self.note(f"! {e.message}") + return + finally: + self._ready.set() + if not rows: + self.note("No message yet. Say something.") + + # ── Drawing ────────────────────────────────────────────────────────────── + + def note(self, text: str, style: str = "italic dim") -> None: + self.log_view.write(Text(text, style=style)) + + def show(self, m: Message) -> None: + day = time.strftime("%Y-%m-%d", time.localtime(m.timestamp)) + if day != self._day: + self._day = day + self.note(f"— {day} —") + line = Text(time.strftime("%H:%M ", time.localtime(m.timestamp)), style="dim") + line.append(f"<{safe(m.sender)}> ", style="bold") + if m.unreadable: + line.append(f"[this message cannot be read: {m.unreadable}]", style="italic") + else: + line.append("\n ".join(safe(part) for part in m.text.split("\n"))) + self.log_view.write(line) + if m.trust == "changed": + self.note(" " + KEY_CHANGED, style="bold yellow") + + # ── What the node pushes ───────────────────────────────────────────────── + + def _pushed(self, msg: dict) -> None: + self.run_worker(self._arrived(msg), group="arrivals") + + async def _arrived(self, msg: dict) -> None: + await self._ready.wait() + if self.keys is None: + return + # A message sealed under an epoch opened since the keys were fetched. + if int(msg.get("epoch") or 0) not in self.keys.by_epoch: + with contextlib.suppress(CliError): + self.keys = await chat_keys(self.session) + msg = {**msg, "timestamp": msg.get("timestamp") or time.time()} + self.show(await open_row(msg, self.keys, self.group["id"], self.check)) + + def _epoch_moved(self, msg: dict) -> None: + """Somebody was removed: a new epoch, and the key to post under with it.""" + self.session.ack["chat_epoch"] = int(msg.get("epoch") or 0) + self.run_worker(self._refetch_keys(), group="keys") + + async def _refetch_keys(self) -> None: + with contextlib.suppress(CliError): + self.keys = await chat_keys(self.session) + + # ── Writing ────────────────────────────────────────────────────────────── + + async def on_input_submitted(self, event: Input.Submitted) -> None: + text = event.value.rstrip() + event.input.value = "" + if not text.strip(): + return + command = text.strip().lower() + if command in ("/part", "/leave"): + self.action_part() + return + if command in ("/quit", "/exit"): + self.app.exit() + return + if command == "/help": + self.note(HELP) + return + if command.startswith("/") and not command.startswith("//"): + self.note(f"Unknown command {safe(text.split()[0])}. {HELP}") + return + if command.startswith("//"): + text = text.lstrip()[1:] # "//x" posts "/x" + await self._ready.wait() + try: + await post(self.session, self.keys, text, self.account.username) + except CliError as e: + self.note(f"! Not sent: {e.message}", style="bold red") + return + # The node relays to everybody else; this side shows its own at once. + self.show(Message(0, time.time(), self.account.username, text, trust="pinned")) + + def action_part(self) -> None: + self.app.pop_screen() diff --git a/packages/meshbay-cli/tests/fake_node.py b/packages/meshbay-cli/tests/fake_node.py index f5fe631..8f4356b 100644 --- a/packages/meshbay-cli/tests/fake_node.py +++ b/packages/meshbay-cli/tests/fake_node.py @@ -32,6 +32,7 @@ from meshbay_common.device import ( from meshbay_common.groupbox import ( PURPOSE_CHAT_KEYS, PURPOSE_INDEX, + PURPOSE_ROSTER, PURPOSE_UPLOAD, seal, unseal, @@ -69,6 +70,12 @@ class FakeNode: self.ack: dict = {"chat_epoch": 1} # the sealed configuration, opened self.chat_epochs = {1: secrets.token_bytes(32)} self.chat: list[dict] = [] # stored rows, oldest first + # group_roster_resp's devices; this machine's own key is a root of its account. + self.roster: list[dict] = [{ + "user_id": USER_ID, "username": "alice-test", "pk_ed25519": self.identity.pk_ed, + "pk_x25519": self.identity.pk_x, "added_by_pk": "", "add_sig": "", + "add_nonce": "", "add_ts": 0}] + self.members: dict[str, Identity] = {} # sender → the device it signs with self.admin_ops: dict[str, dict] = {} def add(self, path: str, name: str, data: bytes, uploader_id: str = "") -> dict: @@ -86,17 +93,47 @@ class FakeNode: return GroupSession(None, FakeChannel(self), GROUP_ID, "node-1", NODE_PK, self.gek, dict(self.ack), self.identity, self.nonce, self.role) + def list_device(self, sender: str, device: Identity, *, + countersigned_by: Identity | None = None, proof: bool = True) -> None: + """A roster row for `sender`'s device: a root, or countersigned by another + of its devices — with the signature, or (`proof=False`) naming the + countersigner and carrying nothing, as a lying node would.""" + row = {"user_id": "u-" + sender, "username": sender, "pk_ed25519": device.pk_ed, + "pk_x25519": device.pk_x, "added_by_pk": "", "add_sig": "", + "add_nonce": "", "add_ts": 0} + if countersigned_by is not None: + row["added_by_pk"] = countersigned_by.pk_ed + if proof: + nonce, ts = secrets.token_bytes(32), 1_700_000_000 + row.update(add_nonce=base64.b64encode(nonce).decode(), add_ts=ts, + add_sig=base64.b64encode(countersigned_by.sk_ed.sign( + device_add_transcript( + node_pk_b64=NODE_PK, user_id="u-" + sender, + pk_ed25519_b64=device.pk_ed, + pk_x25519_b64=device.pk_x, + nonce_node=nonce, ts=ts))).decode()) + self.roster.append(row) + def say(self, text: str, *, sender: str = "bob-test", epoch: int = 1, - forge: bool = False) -> None: - """Another member's message, sealed and signed as a client does; `forge` - signs it with a key other than the device it names.""" - other = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()) + forge: bool = False, device: Identity | None = None) -> Identity: + """Another member's message, sealed and signed as a client does, from + `device` (default: the sender's usual one, listed in the roster as a + root the first time); `forge` signs it with a key other than the device + it names.""" + if device is None: + if sender not in self.members: + self.members[sender] = Identity(Ed25519PrivateKey.generate(), + X25519PrivateKey.generate()) + self.list_device(sender, self.members[sender]) + device = self.members[sender] + other = device sealed = chatbox.seal(self.chat_epochs[epoch], GROUP_ID, epoch, other.pk_ed, base64.b64decode(other.pk_ed), other.sk_ed, {"text": text, "sender_name": sender}) if forge: sealed["sig"] = Ed25519PrivateKey.generate().sign(b"something else") self._store(base64.b64decode(other.pk_ed), epoch, sealed, "u-" + sender) + return other def _store(self, device: bytes, epoch: int, sealed: dict, sender_id: str) -> None: self.chat.append({"id": len(self.chat) + 1, "sender_id": sender_id, @@ -236,6 +273,12 @@ class FakeChannel: return {"type": "chat_keys_resp", "group_id": GROUP_ID, **seal(n.gek, PURPOSE_CHAT_KEYS, "chat_keys_resp", GROUP_ID, payload)} + def _group_roster_req(self, msg): + n = self.node + return {"type": "group_roster_resp", "group_id": GROUP_ID, + **seal(n.gek, PURPOSE_ROSTER, "group_roster_resp", GROUP_ID, + {"devices": n.roster, "node_pk": NODE_PK})} + def _chat_hist(self, msg): rows = self.node.chat if msg.get("before"): diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py index 19f5ca9..cf6a3b9 100644 --- a/packages/meshbay-cli/tests/test_e2e_local.py +++ b/packages/meshbay-cli/tests/test_e2e_local.py @@ -1017,3 +1017,54 @@ def test_chat_between_the_command_line_and_the_application(world): break assert time.monotonic() < deadline, (r.stdout, r.stderr) time.sleep(1) + + +async def test_the_interactive_chat_against_the_real_node(world, monkeypatch, tmp_path): + """The room open on a real group: a message posted from another run arrives + live, and one typed in the room is read back by `chat`. Runs after the + chat test above, on the machine it linked.""" + from meshbay_cli import __main__ as main_module + from meshbay_cli.commands.context import Context + from meshbay_cli.tui.app import MeshBayApp + from meshbay_cli.tui.chat import ChatGroupsScreen, ChatRoomScreen + from textual.widgets import DataTable, Input, RichLog + + group = _handle() + r = await asyncio.to_thread(cli, world, "chat", "-1", group, home="cli-chat") + assert r.returncode == 0, "the chat test above did not leave this machine linked" + monkeypatch.setenv("MESHBAY_CLI_HOME", str(world["root"] / "cli-chat")) + app = MeshBayApp(Context(main_module.build_parser().parse_args( + ["--hub", world["hub_url"]])), ChatGroupsScreen) + + def text(screen) -> str: + return "\n".join("".join(s.text for s in strip) + for strip in screen.query_one(RichLog).lines) + + async def until(pilot, cond, timeout=60): + deadline = time.monotonic() + timeout + while not cond(): + assert time.monotonic() < deadline, [n.message for n in app._notifications] + await pilot.pause(0.1) + + async with app.run_test(size=(140, 40)) as pilot: + await until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen) + and app.screen.query_one(DataTable).row_count >= 1) + await pilot.press("enter") + await until(pilot, lambda: isinstance(app.screen, ChatRoomScreen)) + room = app.screen + await until(pilot, lambda: "hello from the browser" in text(room)) + assert "cannot be read" not in text(room) and "not seen before" not in text(room) + + r = await asyncio.to_thread(cli, world, "chat", group, "-m", + "live from another run", home="cli-chat") + assert r.returncode == 0, r.stderr + await until(pilot, lambda: f"<{USERNAME}> live from another run" in text(room)) + + room.query_one(Input).value = "typed in the room" + await pilot.press("enter") + await until(pilot, lambda: f"<{USERNAME}> typed in the room" in text(room)) + await pilot.press("escape") + await until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen)) + + r = await asyncio.to_thread(cli, world, "chat", "-1", group, home="cli-chat") + assert r.stdout.strip().endswith(f"{USERNAME}: typed in the room"), r.stdout diff --git a/packages/meshbay-cli/tests/test_interactive.py b/packages/meshbay-cli/tests/test_interactive.py index 49180d5..fa7e96c 100644 --- a/packages/meshbay-cli/tests/test_interactive.py +++ b/packages/meshbay-cli/tests/test_interactive.py @@ -206,3 +206,82 @@ async def test_delete_removes_what_is_yours_and_asks_first(world): await pilot.press("delete") await pilot.pause(0.2) assert any("select them on the right" in n.message for n in app._notifications) + + +def _log_text(screen) -> str: + from textual.widgets import RichLog + return "\n".join("".join(seg.text for seg in strip) + for strip in screen.query_one(RichLog).lines) + + +async def test_the_interactive_chat_joins_talks_leaves_and_comes_back(world, monkeypatch): + from meshbay_cli.tui.chat import ChatGroupsScreen, ChatRoomScreen + from textual.widgets import DataTable, Input + hub, node, _ = world + hub.groups.append({"id": "g9", "name": "Quiet", "owner_username": "bob-test", + "node_online": True, "is_admin": False}) + quiet = FakeNode() + quiet.ack["enabled_apps"] = ["files"] + sessions = {} + + async def open_group(account, group): + target = quiet if group["id"] == "g9" else node + sessions[group["id"]] = target.session() + return sessions[group["id"]] + monkeypatch.setattr(pool, "open_group", open_group) + node.say("earlier, from bob") + assert await asyncio.to_thread(cli.main, ["--hub", "https://hub.test", "-u", USERNAME, + "--password", PASSPHRASE, "login"]) == 0 + + app = MeshBayApp(context.Context(cli.build_parser().parse_args( + ["--hub", "https://hub.test"])), ChatGroupsScreen) + async with app.run_test(size=(120, 30)) as pilot: + await _until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen)) + table = app.screen.query_one(DataTable) + await _until(pilot, lambda: table.row_count == 2) + + # A group whose chat is off is not entered. + table.move_cursor(row=1) # Quiet@bob-test + await pilot.press("enter") + await _until(pilot, lambda: any("chat turned off" in n.message + for n in app._notifications)) + assert isinstance(app.screen, ChatGroupsScreen) + + table.move_cursor(row=0) # Family@alice-test + await pilot.press("enter") + await _until(pilot, lambda: isinstance(app.screen, ChatRoomScreen)) + room = app.screen + await _until(pilot, lambda: "<bob-test> earlier, from bob" in _log_text(room)) + + # Posting: sealed, signed, stored on the node, shown here at once. + room.query_one(Input).value = "hello from the room" + await pilot.press("enter") + await _until(pilot, lambda: "<alice-test> hello from the room" in _log_text(room)) + assert len(node.chat) == 2 + + # What the node pushes appears as it arrives. + bob = node.say("live, from bob") + sessions["g1"].ch._emit({**node.chat[-1], "type": "chat_msg"}) + await _until(pilot, lambda: "<bob-test> live, from bob" in _log_text(room)) + assert bob.pk_ed + + # /part goes back; entering again shows the conversation once, and a + # pushed message once — the room left behind no handler. + room.query_one(Input).value = "/part" + await pilot.press("enter") + await _until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen)) + await pilot.press("enter") + await _until(pilot, lambda: isinstance(app.screen, ChatRoomScreen)) + room = app.screen + await _until(pilot, lambda: "live, from bob" in _log_text(room)) + node.say("once") + sessions["g1"].ch._emit({**node.chat[-1], "type": "chat_msg"}) + await _until(pilot, lambda: "<bob-test> once" in _log_text(room)) + await pilot.pause(0.2) + assert _log_text(room).count("<bob-test> once") == 1 + + # Esc goes back too; q quits from the list. + await pilot.press("escape") + await _until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen)) + await pilot.press("q") + assert app.return_code in (0, None) diff --git a/packages/meshbay-cli/tests/test_roster.py b/packages/meshbay-cli/tests/test_roster.py new file mode 100644 index 0000000..cdaf90b --- /dev/null +++ b/packages/meshbay-cli/tests/test_roster.py @@ -0,0 +1,100 @@ +""" +Tier 2 for chat (docs/MESHBAY_DESIGN.md §3.3): whose device signed a message, +checked against the group's roster and the keys this machine accepted before — +through `main()`, against the in-memory node, with real countersignatures. +""" + +import json + +import pytest +from fake_hub import PASSPHRASE, USERNAME, FakeHub +from fake_node import FakeNode +from meshbay_cli import __main__ as cli +from meshbay_cli.client.keys import Identity +from meshbay_cli.client.roster import RosterCheck, judge, verify_roster +from meshbay_cli.commands import chat, context + + +def _new() -> Identity: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + return Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()) + + +@pytest.fixture +def node(monkeypatch, capsys): + hub, fake = FakeHub(), FakeNode() + monkeypatch.setattr(context, "Hub", lambda url: hub) + + async def open_group(account, group): + return fake.session() + monkeypatch.setattr(chat, "open_group", open_group) + assert cli.main(["--hub", hub.url, "-u", USERNAME, "--password", PASSPHRASE, + "login"]) == 0 + capsys.readouterr() + return fake + + +def _last(capsys, n=1) -> list[dict]: + assert cli.main(["--json", "chat", f"-{n}", "Family"]) == 0 + return json.loads(capsys.readouterr().out) + + +def test_each_verdict_on_a_message_signing_device(node, capsys): + phone = node.say("from bob's phone") + assert _last(capsys)[0]["trust"] == "first" # first sight: pinned + assert _last(capsys)[0]["trust"] == "pinned" + + # A second device, countersigned by the first: no notice. + laptop = _new() + node.list_device("bob-test", laptop, countersigned_by=phone) + node.say("from bob's laptop", device=laptop) + assert _last(capsys)[0]["trust"] == "linked" + + # Named as countersigned and carrying no proof, as a lying node would list it. + planted = _new() + node.list_device("bob-test", planted, countersigned_by=phone, proof=False) + node.say("hello, it's bob", device=planted) + assert _last(capsys)[0]["trust"] == "changed" + # Not in the roster at all. + node.say("hello again", device=_new()) + assert _last(capsys)[0]["trust"] == "changed" + + # Said in plain text under the message, and nowhere else. + assert cli.main(["chat", "-5", "Family"]) == 0 + out = capsys.readouterr().out.splitlines() + assert sum("key you have not seen before" in line for line in out) == 2 + assert out[-1].lstrip().startswith("! This account") + assert out[-3].lstrip().startswith("! This account") + + # Another account, first sight. + node.say("hi from carol", sender="carol-test") + assert _last(capsys)[0]["trust"] == "first" + + +async def test_a_device_listed_after_the_roster_was_read_is_not_a_substitution(node): + """The roster is read once per connection; a member's new device added since + is read again before "changed" is said.""" + from meshbay_cli.client.account import Account + from meshbay_cli.client.store import Store + store = Store() + account = Account.open(context.Hub("x"), None, lambda: PASSPHRASE, store) + session = node.session() + phone = node.say("one") + check = RosterCheck(account, session) + assert await check.status("u-bob-test", phone.pk_ed) == "first" + tablet = _new() + node.list_device("bob-test", tablet, countersigned_by=phone) # after the read + assert await check.status("u-bob-test", tablet.pk_ed) == "linked" + + +def test_a_root_is_a_device_naming_no_countersigner(): + a, b = _new(), _new() + rows = [{"user_id": "u", "pk_ed25519": a.pk_ed, "pk_x25519": a.pk_x, + "added_by_pk": ""}, + {"user_id": "u", "pk_ed25519": b.pk_ed, "pk_x25519": b.pk_x, + "added_by_pk": a.pk_ed, "add_sig": "", "add_nonce": "", "add_ts": 0}] + got = verify_roster({"devices": rows, "node_pk": "pk"}, "pk")["u"] + assert got.verified == [a.pk_ed] and got.unevidenced == [b.pk_ed] + assert judge([a.pk_ed], got, b.pk_ed) == ("changed", None) + assert judge(None, got, b.pk_ed)[0] == "first" diff --git a/packages/meshbay-hub/tests/test_account_pinning.py b/packages/meshbay-hub/tests/test_account_pinning.py index ebd29bd..87853fb 100644 --- a/packages/meshbay-hub/tests/test_account_pinning.py +++ b/packages/meshbay-hub/tests/test_account_pinning.py @@ -228,3 +228,35 @@ def test_a_device_pinned_before_the_evidence_existed_reads_as_a_root(): out = _verify([_entry("alice", pk_a), _entry("alice", pk_b, added_by=pk_a)]) assert pk_b in out["alice"]["unevidenced"] + + +def test_the_command_line_client_walks_the_same_chains(): + """`meshbay_cli/client/roster.py` is a port of `_verifyRoster`: held to it on + one roster holding every case — a root, a real chain two deep, a device + claiming a countersigner with no proof, one hanging off it, and a signature + over the wrong transcript.""" + cli_roster = pytest.importorskip("meshbay_cli.client.roster") + sk_a, pk_a = _device() + sk_b, pk_b = _device() + _, pk_c = _device() + _, pk_d = _device() + _, pk_e = _device() + sk_f, pk_f = _device() + unproved = _entry("alice", pk_d, added_by=pk_a) # names A, proves nothing + wrong = _entry("alice", pk_e, added_by=pk_a, sk_signer=sk_a) + wrong["add_ts"] += 1 # signed another transcript + devices = [ + _entry("alice", pk_a), + _entry("alice", pk_b, added_by=pk_a, sk_signer=sk_a), + _entry("alice", pk_c, added_by=pk_b, sk_signer=sk_b), + unproved, + _entry("alice", pk_f, added_by=pk_d, sk_signer=sk_f), # hangs off the unproved + wrong, + _entry("bob", pk_f), + ] + js = _verify(devices) + py = cli_roster.verify_roster({"devices": devices, "node_pk": NODE_PK}, NODE_PK) + for user in ("alice", "bob"): + assert sorted(py[user].verified) == sorted(js[user]["verified"]), user + assert sorted(py[user].unevidenced) == sorted(js[user]["unevidenced"]), user + assert sorted(js["alice"]["verified"]) == sorted([pk_a, pk_b, pk_c]) |