summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/account.py14
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/chat.py15
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/roster.py152
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/chat.py36
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/manpage.py11
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/app.py15
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/chat.py246
-rw-r--r--packages/meshbay-cli/tests/fake_node.py51
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py51
-rw-r--r--packages/meshbay-cli/tests/test_interactive.py79
-rw-r--r--packages/meshbay-cli/tests/test_roster.py100
-rw-r--r--packages/meshbay-hub/tests/test_account_pinning.py32
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])