diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 01:35:09 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 01:35:09 +0200 |
| commit | 47583c5e405f3011a526df73cc9485c46598f214 (patch) | |
| tree | 90fafd2e7d3978071b38402253cf7eb70717ec7f /packages | |
| parent | ced66b96f898abccec2526650ab356eb6b82f066 (diff) | |
| download | meshbay-47583c5e405f3011a526df73cc9485c46598f214.tar.gz | |
feat(cli): chat — read a group's last messages, post one
chat GROUP prints the last ten messages, oldest first (-N, as head -N, or
-n N for another count, paging back past the node's 200), and -m posts one
(- reads it from stdin). The application's construction: every epoch key
from chat_keys_req, a post sealed under the current epoch through the
device subkey and signed with this machine's key on the node, the sender's
name inside the envelope; nothing sent past the node's ciphertext bound.
A stored message is read signature first, then key, then content, and one
that fails is shown as unreadable, never dropped. Not done here: the Tier 2
roster check on the signing device.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
| -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 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/fake_node.py | 61 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_cli_actions.py | 45 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_e2e_local.py | 121 |
7 files changed, 473 insertions, 15 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] diff --git a/packages/meshbay-cli/tests/fake_node.py b/packages/meshbay-cli/tests/fake_node.py index 73d6f72..f5fe631 100644 --- a/packages/meshbay-cli/tests/fake_node.py +++ b/packages/meshbay-cli/tests/fake_node.py @@ -22,13 +22,20 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from fake_hub import USER_ID from meshbay_cli.client.keys import Identity from meshbay_cli.client.session import GroupSession, NodeRefusal +from meshbay_common import chatbox from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript from meshbay_common.device import ( device_add_transcript, device_code_hash, device_revoke_transcript, ) -from meshbay_common.groupbox import PURPOSE_INDEX, PURPOSE_UPLOAD, seal, unseal +from meshbay_common.groupbox import ( + PURPOSE_CHAT_KEYS, + PURPOSE_INDEX, + PURPOSE_UPLOAD, + seal, + unseal, +) from meshbay_common.protocol import UPLOAD_PROBE_INDEX, file_upload_ack_wire from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes @@ -59,7 +66,9 @@ class FakeNode: "pinned_via": "code"}] self.requests: list[dict] = [] self.role = "" # "operator": this key was paired here - self.ack: dict = {} # the sealed configuration, opened + 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 self.admin_ops: dict[str, dict] = {} def add(self, path: str, name: str, data: bytes, uploader_id: str = "") -> dict: @@ -77,6 +86,25 @@ 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 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()) + 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) + + 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, + "sender_name": "", "timestamp": 1_700_000_000 + len(self.chat), + "thread_id": None, "format": 1, "epoch": epoch, + "device": device, "nonce": sealed["nonce"], "sig": sealed["sig"], + "payload": "", "ct": sealed["ct"]}) + def request_device(self, code: str) -> Identity: """Another machine files a request with `code`, as it would before its proof.""" other = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()) @@ -199,6 +227,35 @@ class FakeChannel: dir=folder) + # ── Chat ───────────────────────────────────────────────────────────────── + + def _chat_keys_req(self, msg): + n = self.node + payload = {"epochs": [{"epoch": e, "key": k} for e, k in sorted(n.chat_epochs.items())], + "current": max(n.chat_epochs)} + return {"type": "chat_keys_resp", "group_id": GROUP_ID, + **seal(n.gek, PURPOSE_CHAT_KEYS, "chat_keys_resp", GROUP_ID, payload)} + + def _chat_hist(self, msg): + rows = self.node.chat + if msg.get("before"): + rows = [r for r in rows if r["id"] < msg["before"]] + page = rows[-min(int(msg["limit"]), 200):] + return {"type": "chat_hist_resp", "messages": page, + "has_more": bool(page) and page[0]["id"] > rows[0]["id"]} + + def _chat_msg(self, msg): + n = self.node + if bytes(msg["device"]) != base64.b64decode(n.identity.pk_ed): + return {"type": "error", "detail": "That is not the device on this connection"} + if msg["epoch"] != max(n.chat_epochs) or msg["format"] != 1: + return {"type": "error", "detail": "stale epoch or plaintext"} + if not chatbox.verify(GROUP_ID, msg["epoch"], bytes(msg["device"]), msg["nonce"], + msg["ct"], msg["sig"]): + return {"type": "error", "detail": "bad signature"} + n._store(bytes(msg["device"]), msg["epoch"], msg, USER_ID) + return {"type": "ack"} + # ── Deleting: a challenge, signed with this machine's key ────────────── def _file_delete(self, msg): diff --git a/packages/meshbay-cli/tests/test_cli_actions.py b/packages/meshbay-cli/tests/test_cli_actions.py index e06fb9c..5443d9b 100644 --- a/packages/meshbay-cli/tests/test_cli_actions.py +++ b/packages/meshbay-cli/tests/test_cli_actions.py @@ -510,6 +510,51 @@ def test_search_finds_across_groups_once_per_file(hub, monkeypatch, capsys): assert main("search", "beach") == 0 +def test_chat_reads_the_last_messages_and_posts(node, monkeypatch, capsys): + from meshbay_cli.commands import chat + + async def open_group(account, group): + return node.session() + monkeypatch.setattr(chat, "open_group", open_group) + + for i in range(1, 231): # more than one page of the node's + node.say(f"message {i}") + node.say("forged", forge=True) + node.say("two lines\nand an \x1b[31m escape") + node.chat_epochs[2] = b"\x09" * 32 + node.say("from an epoch this member was not given", epoch=2) + del node.chat_epochs[2] + + assert main("chat", "Family") == 0 + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 11 and lines[0].endswith("bob-test: message 224") + assert lines[7].endswith("bob-test: [this message cannot be read: signature]") + assert lines[8].endswith("bob-test: two lines") + assert lines[9].strip() == "and an \\x1b[31m escape" # shown, never run + assert "cannot be read: no key for its epoch" in lines[10] + + assert main("chat", "-5", "Family") == 0 + assert capsys.readouterr().out.splitlines()[0].endswith("message 229") + assert main("--json", "chat", "Family", "-n", "300") == 0 # paged back + got = json.loads(capsys.readouterr().out) + assert len(got) == 233 and got[0]["text"] == "message 1" + assert got[230]["unreadable"] == "signature" + + assert main("chat", "Family", "-m", "hello from the terminal") == 0 + assert "Posted to Family@alice-test" in capsys.readouterr().err + monkeypatch.setattr("sys.stdin", io.StringIO("piped\n")) + assert main("chat", "Family", "-m", "-") == 0 + capsys.readouterr() + assert main("chat", "-2", "Family") == 0 + out = capsys.readouterr().out.splitlines() + assert out[0].endswith(f"{USERNAME}: hello from the terminal") + assert out[1].endswith(f"{USERNAME}: piped") + + assert main("chat", "Family", "-m", " ") == 1 + assert main("chat", "Family", "-m", "x" * 70_000) == 1 # over the node's bound + assert main("chat", "-0", "Family") == 1 + + def test_every_action_is_run_here(): """Runs last in this module: every action the parser knows was run above.""" assert RUN == set(cli.ACTIONS), f"never run: {set(cli.ACTIONS) - RUN}" diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py index 8ce3ebc..19f5ca9 100644 --- a/packages/meshbay-cli/tests/test_e2e_local.py +++ b/packages/meshbay-cli/tests/test_e2e_local.py @@ -312,6 +312,27 @@ class App: assert r.get("detail") == "keypair_bundle_deleted", r await close() + async def revoke_the_others(self) -> None: + """Retire every device of the account but this one: the node admits five, + and the tests link more machines than that between them.""" + from meshbay_cli.client.keys import b64 + from meshbay_common import MNP_VERSION + from meshbay_common.device import device_revoke_transcript + ch, close, node_pk, nonce_s, account = await self.session() + listed = await ch.request({"type": "device_list", "v": MNP_VERSION}, + "device_list_result") + for d in listed["devices"]: + if d["pk_ed25519"] == self.ident.pk_ed: + continue + ts = int(time.time()) + sig = self.ident.sk_ed.sign(device_revoke_transcript( + node_pk_b64=node_pk, user_id=account.user_id, + pk_ed25519_b64=d["pk_ed25519"], nonce_node=nonce_s, ts=ts)) + await ch.request({"type": "device_revoke", "v": MNP_VERSION, + "pk_ed25519": d["pk_ed25519"], "ts": ts, "sig": b64(sig)}, + "device_add_ack") + await close() + async def approve(self, code: str) -> None: """Approve a device by the code it shows, as the group's Settings tab does.""" from meshbay_cli.client.keys import b64 @@ -668,7 +689,9 @@ _LINK_FORM = r"""(() => { return { form: Boolean(form), button: Boolean(form && form.querySelector('button')), code: code || '', minted: window.__minted, error: (document.querySelector('.error-msg') || {}).innerText || '', - opened: /No messages yet/.test(page), page: page.slice(-400) }; + // The group open on its Chat tab, the composer ready. + opened: Boolean(document.querySelector('.chat-input:not([disabled])')), + page: page.slice(-400) }; })()""" @@ -730,12 +753,10 @@ class _Chrome: self.profile.cleanup() -@pytest.mark.skipif(not shutil.which("google-chrome"), - reason="google-chrome is not available") -def test_the_application_links_itself_from_its_group_page(world): +def _session_for_the_page(world: dict) -> dict: + """What the page keeps in `mb_auth` after signing in, for the account.""" from meshbay_cli.client.keys import auth_key - app, hub_url = world["app"], world["hub_url"] - asyncio.run(app.withdraw_bundle()) + hub_url = world["hub_url"] deadline = time.monotonic() + 90 while (r := httpx.post(f"{hub_url}/v1/users/login", json={ "username": USERNAME, "auth_key": auth_key(USERNAME, PASSPHRASE)}) @@ -745,12 +766,19 @@ def test_the_application_links_itself_from_its_group_page(world): tokens = r.json() me = httpx.get(f"{hub_url}/v1/users/me", headers={"Authorization": f"Bearer {tokens['access_token']}"}).json() - auth = {"username": USERNAME, "userId": me["user_id"], "role": "user", + return {"username": USERNAME, "userId": me["user_id"], "role": "user", "token": tokens["access_token"], "refreshToken": tokens.get("refresh_token")} - chrome = _Chrome(_KEYS_STUB.replace("__AUTH__", json.dumps(auth))) + +def _linked_group_page(world: dict) -> "_Chrome": + """The group page in Chrome as the desktop application, linked as a new + device by its own "Get a linking code" and open. The caller closes it.""" + app = world["app"] + asyncio.run(app.withdraw_bundle()) + chrome = _Chrome(_KEYS_STUB.replace("__AUTH__", json.dumps(_session_for_the_page(world)))) try: - chrome.call("Page.navigate", url=f"{hub_url}/#/group/{world['group_id']}") + chrome.call("Page.navigate", + url=f"{world['hub_url']}/#/group/{world['group_id']}") # Refused as an unknown device: the form, and nothing shown as an error. s = chrome.until(_LINK_FORM, lambda s: s["button"]) assert not s["error"], s @@ -763,9 +791,16 @@ def test_the_application_links_itself_from_its_group_page(world): s = chrome.until(_LINK_FORM, lambda s: s["opened"] and not s["form"], timeout=60) assert not s["error"], s assert s["minted"] == 1, "the identity linked is not the one kept" - finally: + return chrome + except BaseException: chrome.close() + raise + +@pytest.mark.skipif(not shutil.which("google-chrome"), + reason="google-chrome is not available") +def test_the_application_links_itself_from_its_group_page(world): + _linked_group_page(world).close() # ── A second account: invited, joined, its devices, browser access, leaving ── @@ -916,3 +951,69 @@ def test_search_finds_a_file_and_get_takes_its_path(world, tmp_path): assert (tmp_path / "plage de l'ouest.jpg").stat().st_size == 3000 r = cli(world, "search", "no-such-thing-anywhere", home="cli-search") assert r.returncode == 3 and "Nothing found" in r.stderr + + +def _cli_signed_in(world: dict, home: str) -> None: + deadline = time.monotonic() + 90 + while True: + r = cli(world, "--hub", world["hub_url"], "-u", USERNAME, "login", + "--password-file", "-", stdin=PASSPHRASE + "\n", home=home) + if r.returncode == 0: + return + assert "Too many attempts" in r.stderr and time.monotonic() < deadline, r.stderr + time.sleep(5) + + +@pytest.mark.skipif(not shutil.which("google-chrome"), + reason="google-chrome is not available") +def test_chat_between_the_command_line_and_the_application(world): + """Posted here, read on the group page in Chrome; answered there, read here — + the Python seal and the page's crypto.js, on a real node.""" + group = _handle() + asyncio.run(world["app"].revoke_the_others()) # room for this machine and the page + _cli_signed_in(world, "cli-chat") + r = cli(world, "chat", group, "-m", "hello from the terminal 1", home="cli-chat") + if r.returncode == 5: # a new device here: approved first + found = re.search(r"enter\s+([0-9A-Z]{4}-[0-9A-Z]{4})", r.stderr) + assert found, r.stderr + code = found.group(1) + asyncio.run(world["app"].approve(code)) + r = cli(world, "chat", group, "-m", "hello from the terminal 1", home="cli-chat") + assert r.returncode == 0, r.stderr + for i in range(2, 13): + r = cli(world, "chat", group, "-m", f"hello from the terminal {i}", home="cli-chat") + assert r.returncode == 0, r.stderr + + r = cli(world, "chat", group, home="cli-chat") + assert r.returncode == 0, r.stderr + lines = r.stdout.splitlines() + assert len(lines) == 10 + assert lines[0].endswith(f"{USERNAME}: hello from the terminal 3") + assert lines[-1].endswith(f"{USERNAME}: hello from the terminal 12") + r = cli(world, "chat", "-100", group, home="cli-chat") + assert len(r.stdout.splitlines()) == 12, r.stdout + + chrome = _linked_group_page(world) + try: + # The page opens the terminal's messages: signature, epoch key, content. + chrome.until("document.body.innerText", + lambda text: "hello from the terminal 12" in (text or "")) + assert "cannot be read" not in chrome.js("document.body.innerText").lower() + chrome.js("""(() => { + const box = document.querySelector('.chat-input'); + box.value = 'hello from the browser'; + box.dispatchEvent(new Event('input', { bubbles: true })); + })()""") + chrome.until("!document.querySelector('.chat-send').disabled", bool) + chrome.js("document.querySelector('.chat-send').click()") + chrome.until("document.querySelector('.chat-input').value === ''", bool) + finally: + chrome.close() + + deadline = time.monotonic() + 30 + while True: + r = cli(world, "chat", "-1", group, home="cli-chat") + if r.stdout.strip().endswith(f"{USERNAME}: hello from the browser"): + break + assert time.monotonic() < deadline, (r.stdout, r.stderr) + time.sleep(1) |