diff options
Diffstat (limited to 'packages/meshbay-cli')
27 files changed, 1523 insertions, 60 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/__main__.py b/packages/meshbay-cli/src/meshbay_cli/__main__.py index 2b0b349..91e523a 100644 --- a/packages/meshbay-cli/src/meshbay_cli/__main__.py +++ b/packages/meshbay-cli/src/meshbay_cli/__main__.py @@ -11,12 +11,28 @@ import os import sys from . import __version__ -from .commands import get, groups, login, logout, ls, put, trust +from .commands import ( + browser_access, + decline, + devices, + get, + groups, + invitations, + join, + leave, + login, + logout, + ls, + put, + trust, +) from .commands.context import Context from .errors import INTERRUPTED, USAGE, CliError -ACTIONS = {"login": login, "logout": logout, "groups": groups, "ls": ls, "get": get, - "put": put, "trust": trust} +ACTIONS = {"login": login, "logout": logout, "groups": groups, "invitations": invitations, + "join": join, "decline": decline, "leave": leave, "ls": ls, "get": get, + "put": put, "devices": devices, "trust": trust, + "browser-access": browser_access} class _Parser(argparse.ArgumentParser): @@ -49,8 +65,8 @@ def build_parser() -> argparse.ArgumentParser: """Global options are accepted before the action and after it.""" p = _Parser( prog="meshbay-cli", - description="A member's MeshBay files, from a terminal. Run with no argument " - "for the interactive mode.") + description="A member's MeshBay groups and files, from a terminal. Run with no " + "argument for the interactive mode.") _global_options(p, None) p.add_argument("--version", action="version", version=f"meshbay-cli {__version__}") # SUPPRESS: an option the action's own parser did not see must not diff --git a/packages/meshbay-cli/src/meshbay_cli/client/account.py b/packages/meshbay-cli/src/meshbay_cli/client/account.py index d620f6d..9bb85ce 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/account.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/account.py @@ -163,11 +163,14 @@ class Account: # A node's key does not depend on who signed in. "pins": previous.get("pins", {}) if previous.get("hub") == self.hub.url else {}, } + if same_account and previous.get("browser_access"): + state["browser_access"] = previous["browser_access"] secrets = { "access_token": token, "access_exp": time.time() + int(r.get("expires_in", 0)), "refresh_token": r["refresh_token"], "master": b64(master), + "pepper_version": int(r.get("bundle_pepper_version") or 1), "legacy": b64(a), "identities": self.secrets.get("identities", {}) if same_account else {}, } @@ -306,6 +309,38 @@ class Account: secrets.setdefault("identities", {})[node_pk] = p["keys"] await self.update(move) + # An invitation's code waiting for its node: kept when the node was not + # reachable at `join`, sent by the next connection to it, dropped once used + # or refused. A link's code names its node; an invitation's does not. + + def join_code(self, group_id: str, node_pk: str) -> str | None: + c = self.secrets.get("join_codes", {}).get(group_id) + if not c or (c.get("node_pk") and c["node_pk"] != node_pk): + return None + return c["code"] + + async def keep_join_code(self, group_id: str, code: str, node_pk: str | None) -> None: + def put(state: dict, secrets: dict) -> None: + secrets.setdefault("join_codes", {})[group_id] = {"code": code, + "node_pk": node_pk} + await self.update(put) + + async def drop_join_code(self, group_id: str) -> None: + def drop(state: dict, secrets: dict) -> None: + secrets.get("join_codes", {}).pop(group_id, None) + if group_id in self.secrets.get("join_codes", {}): + await self.update(drop) + + @property + def browser_access(self) -> str | None: + """This machine's setting: "on", "off", or None (unset — leave the node + as it is). Set only by `browser-access`; never from the hub.""" + return self.state.get("browser_access") + + @property + def pepper_version(self) -> int: + return int(self.secrets.get("pepper_version") or 1) + 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/devices.py b/packages/meshbay-cli/src/meshbay_cli/client/devices.py new file mode 100644 index 0000000..cb30701 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/devices.py @@ -0,0 +1,101 @@ +""" +This account's devices on one node: what the application's "Your devices on this +node" does (docs/MESHBAY_DESIGN.md §3.3). + +Every message here is sent on an authenticated session and signed by this +machine's key on the node, over the connection's nonce: the node admits or +retires a key only on the word of a device it already pinned. +""" + +import time + +from meshbay_common import MNP_VERSION +from meshbay_common.device import ( + device_add_transcript, + device_code_hash, + device_revoke_transcript, +) + +from ..errors import NOT_FOUND, USAGE, CliError +from .account import Account +from .keys import b64 +from .session import GroupSession + + +def normalize_code(code: str) -> str: + """ + A code as typed, folded onto what was generated — `crypto.js` `normalizeCode` + and the node's `roster.normalize_code`: case, dashes and spaces do not count, + and the letters left out of the alphabet read as the digits they resemble. + """ + fold = {"I": "1", "L": "1", "O": "0", "U": "V"} + return "".join(fold.get(c, c) for c in code.upper() if c not in "- \t") + + +async def list_devices(session: GroupSession) -> dict: + """`{devices: [{pk_ed25519, label, pinned_at, pinned_via, is_this_one}], pending}`.""" + r = await session.ch.request({"type": "device_list", "v": MNP_VERSION}, + "device_list_result") + return {"devices": r.get("devices") or [], "pending": int(r.get("pending") or 0)} + + +async def approve(session: GroupSession, account: Account, code: str) -> str: + """ + Countersign the one pending request this code names; its Ed25519 key. + + The code never leaves this machine: each pending request is hashed with it + and must match the hash its device filed. None, or more than one, approves + nothing — filing needs only the account's token, so somebody holding the + passphrase who saw the code could file their own keys under it. + """ + raw = normalize_code(code) + if len(raw) != 8: + raise CliError(USAGE, "A linking code has 8 characters, as XXXX-XXXX.") + listed = await session.ch.request({"type": "device_lookup", "v": MNP_VERSION}, + "device_lookup_result") + matches = [r for r in listed.get("requests") or [] + if device_code_hash(raw, r["pk_ed25519"], r["pk_x25519"]) == r["code_hash"]] + if not matches: + raise CliError(NOT_FOUND, "No device is waiting with that code on this node.") + if len(matches) > 1: + raise CliError(NOT_FOUND, "More than one device is waiting with that code, so " + "none was approved. Get a new code on the device you " + "are linking.") + match = matches[0] + ts = int(time.time()) + sig = session.identity.sk_ed.sign(device_add_transcript( + node_pk_b64=session.node_pk, user_id=account.user_id, + pk_ed25519_b64=match["pk_ed25519"], pk_x25519_b64=match["pk_x25519"], + nonce_node=session.nonce_node, ts=ts)) + await session.ch.request({"type": "device_add", "v": MNP_VERSION, + "pk_ed25519": match["pk_ed25519"], + "pk_x25519": match["pk_x25519"], + "code_hash": match["code_hash"], "ts": ts, "sig": b64(sig)}, + "device_add_ack") + return match["pk_ed25519"] + + +def find_device(devices: list[dict], prefix: str) -> dict: + """The one device whose key starts with `prefix` (8 characters at least).""" + if len(prefix) < 8: + raise CliError(USAGE, "Name the device by the first 8 characters of its key " + "at least, as `devices list` shows it.") + found = [d for d in devices if d["pk_ed25519"].startswith(prefix)] + if not found: + raise CliError(NOT_FOUND, f"No device of yours on this node has a key starting " + f"with {prefix}.") + if len(found) > 1: + raise CliError(USAGE, f"Several devices have a key starting with {prefix}; " + "give more of it.") + return found[0] + + +async def revoke(session: GroupSession, account: Account, pk_ed: str) -> None: + """Retire a device of this account. The node refuses the last one.""" + ts = int(time.time()) + sig = session.identity.sk_ed.sign(device_revoke_transcript( + node_pk_b64=session.node_pk, user_id=account.user_id, pk_ed25519_b64=pk_ed, + nonce_node=session.nonce_node, ts=ts)) + await session.ch.request({"type": "device_revoke", "v": MNP_VERSION, + "pk_ed25519": pk_ed, "ts": ts, "sig": b64(sig)}, + "device_add_ack") diff --git a/packages/meshbay-cli/src/meshbay_cli/client/groups.py b/packages/meshbay-cli/src/meshbay_cli/client/groups.py index ac738f3..4288c32 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/groups.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/groups.py @@ -7,12 +7,15 @@ import time from ..errors import NODE, CliError from .account import Account from .paths import handle -from .session import DeviceLinkPending, GroupSession, NodeKeyChanged, connect +from .session import DeviceLinkPending, GroupSession, NodeKeyChanged, NodeRefusal, connect # How often a machine waiting for its approval tries the node again. Each try is # one offer: well inside what the hub admits per account and per node. LINK_POLL = 10 +# Join refusals about the account rather than the node: every node gives them. +ACCOUNT_REFUSALS = frozenset({"code_required", "code_invalid"}) + log = logging.getLogger(__name__) @@ -20,22 +23,38 @@ async def my_groups(account: Account) -> list[dict]: return await account.call(account.hub.my_groups) -async def open_group(account: Account, group: dict) -> GroupSession: +async def open_group(account: Account, group: dict, *, join_code: str | None = None, + node_pk: str | None = None) -> GroupSession: """ A session with a node serving `group`: the pinned one first, then the others in the hub's order. A node whose key changed stops everything — that is an alarm, not a reason to quietly try its neighbour. + + `join_code` goes in the join; `node_pk` restricts the tries to that node — an + invitation link's code is for the node the link names and no other. """ nodes = await account.call(account.hub.group_nodes, group["id"]) + if node_pk: + nodes = [n for n in nodes if n.get("pk_node") == node_pk] + if not nodes: + raise CliError(NODE, f"The node the invitation names for {handle(group)} " + "is not online.") if not nodes: raise CliError(NODE, f"No node serving {handle(group)} is online.") nodes = sorted(nodes, key=lambda n: account.pin(n["node_id"]) is None) failures = [] for node in nodes: try: - return await _connect_linking(account, group, node) + return await _connect_linking(account, group, node, join_code) except (NodeKeyChanged, DeviceLinkPending): raise + except NodeRefusal as e: + # A refusal about this account — a code to give — is the same answer + # from every node; trying the next would only bury it. + if e.refusal_code in ACCOUNT_REFUSALS or len(nodes) == 1: + raise + log.info("node %s: %s", node["node_id"], e.message) + failures.append(f"{node['node_id']}: {e.message}") except CliError as e: if e.code != NODE or len(nodes) == 1: raise @@ -45,7 +64,8 @@ async def open_group(account: Account, group: dict) -> GroupSession: + "\n ".join(failures)) -async def _connect_linking(account: Account, group: dict, node: dict) -> GroupSession: +async def _connect_linking(account: Account, group: dict, node: dict, + join_code: str | None = None) -> GroupSession: """ `connect`, and — when this machine is waiting to be approved as a device on that node — the wait: the code is shown once (`account.on_device_code`), and @@ -55,7 +75,7 @@ async def _connect_linking(account: Account, group: dict, node: dict) -> GroupSe shown = False while True: try: - return await connect(account, group["id"], node) + return await connect(account, group["id"], node, join_code) except DeviceLinkPending as pending: hook = account.on_device_code wait = hook(group, pending, first=not shown) if hook else False diff --git a/packages/meshbay-cli/src/meshbay_cli/client/hub.py b/packages/meshbay-cli/src/meshbay_cli/client/hub.py index c8ce0c1..94d22da 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/hub.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/hub.py @@ -1,9 +1,10 @@ """ The hub's API, as a member's client uses it. -The hub signs in, lists groups and the nodes serving them, mints the token a -node is shown, and relays the WebRTC offer. Nothing else: no listing, no file -and no byte of content passes through it (docs/MESHBAY_DESIGN.md §1.3, §8.4). +The hub signs in, lists groups and the nodes serving them, keeps memberships +(invitations, links, leaving) and preferences, mints the token a node is shown, +and relays the WebRTC offer. Nothing else: no listing, no file and no byte of +content passes through it (docs/MESHBAY_DESIGN.md §1.3, §8.4). """ import logging @@ -147,6 +148,40 @@ class Hub: raise CliError(NODE, f"The node did not answer the offer: {e.detail}") from e return r["sdp"] + # ── Joining and leaving ────────────────────────────────────────────────── + + async def invitations(self, token: str) -> list[dict]: + return (await self._call("GET", "/v1/groups/invitations", + token=token))["invitations"] + + async def accept_invitation(self, token: str, group_id: str) -> dict: + return await self._call("POST", f"/v1/groups/{group_id}/invitation/accept", + token=token) + + async def decline_invitation(self, token: str, group_id: str) -> dict: + return await self._call("POST", f"/v1/groups/{group_id}/invitation/decline", + token=token) + + async def preview_link(self, token: str, ticket: str) -> dict: + return await self._call("POST", "/v1/invite-links/preview", token=token, + json={"ticket": ticket}) + + async def redeem_link(self, token: str, ticket: str) -> dict: + return await self._call("POST", "/v1/invite-links/redeem", token=token, + json={"ticket": ticket}) + + async def leave_group(self, token: str, group_id: str) -> dict: + return await self._call("POST", f"/v1/groups/{group_id}/leave", token=token) + + # ── Preferences ────────────────────────────────────────────────────────── + + async def preferences(self, token: str) -> dict: + return await self._call("GET", "/v1/users/me/preferences", token=token) + + async def set_preference(self, token: str, key: str, value: str) -> dict: + return await self._call("PUT", f"/v1/users/me/preferences/{key}", token=token, + json={"value": value}) + def _sign_in_refusal(e: HubError) -> CliError: if e.status == 401: diff --git a/packages/meshbay-cli/src/meshbay_cli/client/invite.py b/packages/meshbay-cli/src/meshbay_cli/client/invite.py new file mode 100644 index 0000000..6b26c97 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/invite.py @@ -0,0 +1,50 @@ +""" +Invitation links: `https://<hub>/#/invite?v=1&g=<group>&t=<ticket>&n=<node key>&c=<CODE>`. + +The shape `static/invite-link.js` reads and `api/invite_links.py` writes +(docs/MESHBAY_DESIGN.md §3.4). Two secrets: the ticket is for the hub, which +makes the first account to redeem it a member; the code is for the node the +link names, and goes to no other node and never to the hub. Every field is +checked for its exact shape, as the application checks it. +""" + +import re +from dataclasses import dataclass +from urllib.parse import parse_qs, urlsplit + +_GROUP = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +_TICKET = re.compile(r"^[A-Za-z0-9_-]{22}$") +_NODE_PK = re.compile(r"^[A-Za-z0-9_-]{43}$") +_CODE = re.compile(r"^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$") + + +@dataclass +class InviteLink: + origin: str # the hub the link was made on, '' when only the fragment was given + group_id: str + ticket: str + node_pk: str # standard base64, as the hub lists node keys + code: str + + +def node_pk_from_link(n: str) -> str: + """The link's URL-safe, unpadded key, as the hub and the transport write it.""" + b64 = n.replace("-", "+").replace("_", "/") + return b64 + "=" * (-len(b64) % 4) + + +def parse_link(text: str) -> InviteLink | None: + """The link in `text`, or None when it holds none (or a malformed one).""" + s = text.strip() + at = s.find("/invite?") + if at < 0: + return None + q = {k: v[0] for k, v in parse_qs(s[at + len("/invite?"):]).items()} + g, t, n = q.get("g", ""), q.get("t", ""), q.get("n", "") + c = q.get("c", "").upper() + if q.get("v") != "1" or not (_GROUP.match(g) and _TICKET.match(t) + and _NODE_PK.match(n) and _CODE.match(c)): + return None + parts = urlsplit(s) + origin = f"{parts.scheme}://{parts.netloc}" if parts.scheme and parts.netloc else "" + return InviteLink(origin, g, t, node_pk_from_link(n), c) diff --git a/packages/meshbay-cli/src/meshbay_cli/client/keys.py b/packages/meshbay-cli/src/meshbay_cli/client/keys.py index faca01e..ecf44a5 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/keys.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/keys.py @@ -10,6 +10,7 @@ look like an error — it looks like an account nobody can open. import base64 import hashlib import json +import os from dataclasses import dataclass from argon2.low_level import Type, hash_secret_raw @@ -77,8 +78,9 @@ def open_bundle(bundle_b64: str, *, master: bytes | None, legacy: bytes | None, `MBK3` opens under the node's key derived from `M`, with the account and the node as associated data. `MBK2` opens under `A` alone; it is read so a member - is not locked out of a node still holding one, and never rewritten here — - the application or a browser replaces it. Anything older is refused by name. + is not locked out of a node still holding one, and never written — an `MBK3` + replaces it where this machine leaves bundles (`browser-access on`). Anything + older is refused by name. """ try: raw = unb64(bundle_b64) @@ -106,6 +108,33 @@ def open_bundle(bundle_b64: str, *, master: bytes | None, legacy: bytes | None, return json.loads(plain) +def seal_bundle(keys: dict, *, master: bytes, pepper_version: int, user_id: str, + node_pk: str) -> str: + """`{skEd, skX}` sealed as `MBK3`, as `keyring.js` `seal` writes it: the magic, + the pepper version, a 96-bit nonce, then AES-GCM under the node's bundle key + with the account and the node as associated data.""" + nonce = os.urandom(12) + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + plain = json.dumps({"skEd": keys["skEd"], "skX": keys["skX"]}).encode() + ct = AESGCM(node_bundle_key(master, node_pk)).encrypt(nonce, plain, aad) + return b64(BUNDLE_MAGIC + bytes([pepper_version & 0xFF]) + nonce + ct) + + +def bundle_is_current(bundle_b64: str, *, master: bytes, user_id: str, + node_pk: str) -> bool: + """Whether a bundle is `MBK3` and opens under this `M`: one sealed under an + earlier passphrase, or `MBK2`, is to be sealed again.""" + try: + raw = unb64(bundle_b64) + if raw[:4] != BUNDLE_MAGIC: + return False + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + AESGCM(node_bundle_key(master, node_pk)).decrypt(raw[5:17], raw[17:], aad) + return True + except Exception: + return False + + @dataclass class Identity: """One account's keys on one node.""" diff --git a/packages/meshbay-cli/src/meshbay_cli/client/paths.py b/packages/meshbay-cli/src/meshbay_cli/client/paths.py index 1086085..67119f9 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/paths.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/paths.py @@ -50,6 +50,15 @@ def parse(text: str) -> RemotePath | None: return RemotePath(decoded[0], decoded[1:], from_link) +def group_arg(text: str) -> str: + """A group named on the command line, as `groups` prints it (`%2F` for a `/`), + or as a link to it: the name, decoded, with its `@owner` if given.""" + path = parse(text) + if path is None or path.parts: + raise CliError(USAGE, f"Name a group, not a path: {text!r}") + return path.group + + def encode_name(name: str) -> str: """A group or file name as it is typed in a path.""" return name.replace("%", "%25").replace("/", "%2F") diff --git a/packages/meshbay-cli/src/meshbay_cli/client/session.py b/packages/meshbay-cli/src/meshbay_cli/client/session.py index 1ea398a..d5264a2 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/session.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/session.py @@ -9,12 +9,15 @@ from the handshake on goes straight to the node, sealed under the group key 2. the DataChannel, and the channel binding from both DTLS fingerprints; 3. the handshake: the node signs its challenge with the key it announces, and that key must be the one pinned for this node, if any (§5.2, TOFU); -4. this account's identity on the node — kept from an earlier run, or opened - from the bundle the node keeps for browsers; -5. the group key, wrapped by the node for that identity (`join_request`); +4. this account's identity on the node — kept from an earlier run, opened + from the bundle the node keeps for browsers, or new (an invitation's code, + or device linking); +5. the group key, wrapped by the node for that identity (`join_request`, with + the invitation's code when there is one); 6. the mutual proof, the ack verified and unsealed — a payload that does not open ends the session, never reads as an empty group (§4.4); -7. `device_hello`, so the node knows which of the account's devices this is. +7. `device_hello`, so the node knows which of the account's devices this is; +8. what `browser-access` says to leave on the node: a sealed copy, or none. Every request carries a `req_id` and its reply is matched on it (§5.3): matching by arrival order is a guess that fails silently, and against the unrelated @@ -60,7 +63,15 @@ from meshbay_common.join import join_transcript from ..errors import NODE, NODE_KEY, VERSION, CliError from .account import Account -from .keys import BundleError, Identity, b64, open_bundle, unb64 +from .keys import ( + BundleError, + Identity, + b64, + bundle_is_current, + open_bundle, + seal_bundle, + unb64, +) log = logging.getLogger(__name__) @@ -185,7 +196,8 @@ class GroupSession: """An authenticated connection to one node, for one group.""" def __init__(self, pc: RTCPeerConnection, ch: Channel, group_id: str, - node_id: str, node_pk: str, gek: bytes, ack: dict): + node_id: str, node_pk: str, gek: bytes, ack: dict, + identity: Identity | None = None, nonce_node: bytes = b""): self.pc = pc self.ch = ch self.group_id = group_id @@ -193,6 +205,10 @@ class GroupSession: self.node_pk = node_pk self.gek = gek self.ack = ack + # What a device message is signed with: this machine's key on the node, + # and the nonce that ties a signature to this connection. + self.identity = identity + self.nonce_node = nonce_node async def index(self) -> dict: """`{version, entries, dirs, roots}`, opened under the group key.""" @@ -208,8 +224,13 @@ class GroupSession: await self.pc.close() -async def connect(account: Account, group_id: str, node: dict) -> GroupSession: - """Open a session with `node` (a `/v1/groups/{id}/nodes` row) for `group_id`.""" +async def connect(account: Account, group_id: str, node: dict, + join_code: str | None = None) -> GroupSession: + """ + Open a session with `node` (a `/v1/groups/{id}/nodes` row) for `group_id`. + `join_code`: an invitation's code, for a node that has not admitted this + account yet; otherwise one kept for this group and node is sent. + """ node_id, listed_pk = node["node_id"], node["pk_node"] pinned = account.pin(node_id) if pinned and pinned != listed_pk: @@ -236,7 +257,7 @@ async def connect(account: Account, group_id: str, node: dict) -> GroupSession: "here).") from None binding = webrtc_binding(_dtls_fingerprint(offer_sdp), _dtls_fingerprint(answer_sdp)) return await _handshake(account, pc, ch, group_id, node_id, listed_pk, - mnp_token, binding) + mnp_token, binding, join_code) except BaseException: with contextlib.suppress(Exception): await pc.close() @@ -244,7 +265,8 @@ async def connect(account: Account, group_id: str, node: dict) -> GroupSession: async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: str, - listed_pk: str, mnp_token: str, binding: bytes) -> GroupSession: + listed_pk: str, mnp_token: str, binding: bytes, + join_code: str | None) -> GroupSession: nonce_c = os.urandom(32) challenge = await ch.request( {"type": "handshake", "v": MNP_VERSION, "v_min": MNP_MIN_SUPPORTED, @@ -266,24 +288,39 @@ async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: raise CliError(NODE, "The node's challenge is not signed by the key it " "announces.") from e - identity, source = await _identity(account, ch, node_pk) + identity, source, bundle = await _identity(account, ch, node_pk) + if bundle is None and account.browser_access: + # Whether the node holds a copy, and a current one, decides what the + # setting does after the proof. Asked here: the fetch is a pre-proof + # message, and the window allows it. + bundle = await _fetch_bundle(ch) + code = join_code or account.join_code(group_id, node_pk) or "" ts = int(time.time()) joined = await ch.request({ "type": "join_request", "v": MNP_VERSION, "group_id": group_id, - "pk_ed25519": identity.pk_ed, "pk_x25519": identity.pk_x, "code": "", "ts": ts, + "pk_ed25519": identity.pk_ed, "pk_x25519": identity.pk_x, "code": code, "ts": ts, "sig": b64(identity.sk_ed.sign(join_transcript( node_pk_b64=node_pk, group_id=group_id, user_id=account.user_id, pk_ed25519_b64=identity.pk_ed, pk_x25519_b64=identity.pk_x, nonce_node=nonce_s, ts=ts)))}, "join_result") if not joined.get("ok") or not joined.get("gek"): reason = str(joined.get("reason") or "") + if reason == "code_invalid": + # Spent, cancelled or mistyped: it will not work on another try. + await account.drop_join_code(group_id) if reason == "unknown_device" and source != "bundle": await _link(account, ch, node_pk, nonce_s, identity, source) raise NodeRefusal(JOIN_REFUSALS.get(reason, reason or "the group key was not given"), reason) if source == "pending": await account.adopt_pending(node_pk) + elif source == "new": + # Admitted with a code, or by an open group: these keys are now pinned + # there, and an identity not kept would strand the account on the node. + await account.keep_identity(node_pk, identity) + if code: + await account.drop_join_code(group_id) gek = unwrap_gek_aes(joined, identity.sk_x.private_bytes_raw(), identity.sk_x.public_key().public_bytes_raw()) @@ -312,40 +349,80 @@ async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: await account.set_pin(node_id, node_pk) await _device_hello(account, ch, identity, node_pk, group_id, nonce_s) - return GroupSession(pc, ch, group_id, node_id, node_pk, gek, config) + await _settle_bundle(account, ch, identity, node_pk, bundle) + return GroupSession(pc, ch, group_id, node_id, node_pk, gek, config, + identity, nonce_s) + + +async def _fetch_bundle(ch: Channel) -> str: + """The node's copy of this account's keys, or "" when it keeps none.""" + found = await ch.request({"type": "keypair_bundle_fetch", "v": MNP_VERSION}, + "keypair_bundle_resp") + return str(found.get("bundle_enc") or "") if found.get("found") else "" -async def _identity(account: Account, ch: Channel, node_pk: str) -> tuple[Identity, str]: +async def _identity(account: Account, ch: Channel, + node_pk: str) -> tuple[Identity, str, str | None]: """ - This account's keys on this node, and where they came from: kept from an - earlier run, waiting for a device approval, opened from the copy the node - keeps for browsers — or, failing all three, new, for device linking. + This account's keys on this node, where they came from, and the node's bundle + when it was fetched (None: not asked). Kept from an earlier run, waiting for + a device approval, opened from the copy the node keeps for browsers — or, + failing all three, new: for an invitation's code, or for device linking. """ kept = account.identity(node_pk) if kept is not None: - return kept, "kept" + return kept, "kept", None pending = account.pending_identity(node_pk) if pending is not None: - return pending.identity, "pending" - found = await ch.request({"type": "keypair_bundle_fetch", "v": MNP_VERSION}, - "keypair_bundle_resp") - if not found.get("found"): - return Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()), "new" + return pending.identity, "pending", None + bundle = await _fetch_bundle(ch) + if not bundle: + return (Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()), + "new", bundle) try: - keys = open_bundle(found["bundle_enc"], master=account.master, + keys = open_bundle(bundle, master=account.master, legacy=account.legacy, user_id=account.user_id, node_pk=node_pk) identity = Identity.from_pkcs8(keys) except BundleError as e: raise CliError(NODE, str(e)) from e await account.keep_identity(node_pk, identity) - return identity, "bundle" + return identity, "bundle", bundle + + +async def _settle_bundle(account: Account, ch: Channel, identity: Identity, node_pk: str, + bundle: str | None) -> None: + """ + What `browser-access` says to leave on this node (spec §2.12): `on`, a copy + sealed under the current `M` when the node holds none or an outdated one — + a current copy is left alone, whichever device sealed it, so two clients do + not take turns rewriting it; `off`, the node's copy withdrawn; unset, + nothing. A failure costs the browsers their copy, never this session. + """ + setting = account.browser_access + if not setting or bundle is None: + return + try: + if setting == "off" and bundle: + await ch.request({"type": "keypair_bundle_delete", "v": MNP_VERSION}, "ack") + elif setting == "on" and account.master and not bundle_is_current( + bundle, master=account.master, user_id=account.user_id, node_pk=node_pk): + sealed = seal_bundle(identity.to_pkcs8(), master=account.master, + pepper_version=account.pepper_version, + user_id=account.user_id, node_pk=node_pk) + await ch.request({"type": "keypair_bundle_store", "v": MNP_VERSION, + "bundle_enc": sealed}, "ack") + except CliError as e: + log.warning("Browser access is %s here, but the node's copy of your keys was " + "not %s: %s", setting, "withdrawn" if setting == "off" else "stored", + e.message) # What a refused join means, for the person reading it. JOIN_REFUSALS = { - "code_required": "this node has not admitted you yet. Accept the invitation in the " - "MeshBay application and enter its code there; the command line " - "does not join groups", + "code_required": "this node has not admitted you yet; it needs the code of your " + "invitation: meshbay-cli join GROUP --code CODE", + "code_invalid": "the invitation's code was refused: already used, cancelled, or " + "mistyped. Ask the group's owner for a new one", "unknown_device": "the key this node holds for you is not one of your devices there " "any more; ask its operator, or approve this machine from a device " "already linked", diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/browser_access.py b/packages/meshbay-cli/src/meshbay_cli/commands/browser_access.py new file mode 100644 index 0000000..e2b0b8e --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/browser_access.py @@ -0,0 +1,72 @@ +""" +`browser-access`: whether this machine leaves, on each node, a copy of its keys +a browser can open with the passphrase — the application's Profile → Browser +access, kept by this machine (docs/MESHBAY_DESIGN.md §3.7). + +`on`: each group opened here leaves a copy sealed under the passphrase-derived +key, when its node holds none or an outdated one. `off`: the copy a node holds +is withdrawn. Never set: nothing is written or withdrawn, so a CLI nobody +configured never undoes what the application of another computer does. + +Set here and nowhere else. The hub keeps a mirror of it, written here as the +application writes it, but a mirror anyone with a hub session can write must +never make this machine upload keys: read, it only produces a message. +""" + +import json + +from ..client.hub import HubError +from ..errors import AUTH, CliError +from ..output import err, sentence +from .context import Context + +HELP = "whether a browser may open your groups: leave a copy of your keys on nodes, or not" + +EFFECT = { + "on": "Each group opened here from now on leaves a copy of your keys on its node, " + "sealed under your passphrase, so that a browser can open it.", + "off": "Each group opened here from now on withdraws the copy of your keys its node " + "holds; a browser will no longer open it with your passphrase.", +} + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("browser-access", parents=[parent], help=HELP, + description=sentence(HELP) + ". As the application's Browser " + "access, for this machine. With no argument, shows the setting.") + p.add_argument("state", nargs="?", choices=("on", "off")) + + +async def run(ctx: Context) -> int: + a = ctx.args + account = ctx.account() + if not account.remembered: + raise CliError(AUTH, "This setting is kept with the saved session; run " + "`meshbay-cli login` first.") + if a.state is None: + try: + mirror = (await account.call(account.hub.preferences)).get("browser_access") + except (HubError, CliError): + mirror = None + mine = account.browser_access + if a.json: + print(json.dumps({"this_machine": mine, "hub": mirror})) + else: + print(f"This machine: {mine or 'not set (leaves the nodes as they are)'}") + print(f"The hub's copy: {mirror or 'none'} (informative: it sets nothing here)") + return 0 + + def put(state: dict, secrets: dict) -> None: + state["browser_access"] = a.state + await account.update(put) + try: + await account.call(account.hub.set_preference, "browser_access", a.state) + except (HubError, CliError) as e: + err(f"meshbay-cli: warning: the hub's copy of the setting was not updated " + f"({getattr(e, 'detail', None) or getattr(e, 'message', e)}); a browser may " + "explain a closed group less well.") + if a.json: + print(json.dumps({"this_machine": a.state})) + else: + print(f"Browser access: {a.state}. {EFFECT[a.state]}") + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/context.py b/packages/meshbay-cli/src/meshbay_cli/commands/context.py index c11533b..827c101 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/context.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/context.py @@ -71,6 +71,8 @@ class Context: "this group:\n" " the group's Settings → \"Your devices on this node\" → enter " f"{pending.link_code} → Approve\n" + "or from a machine already linked:\n" + f" meshbay-cli devices approve {handle(group)} {pending.link_code}\n" + (f"Waiting for the approval (the code expires in {minutes} min; " "Ctrl-C to stop)..." if waiting else f"Run the command again once it is approved (the code expires in " diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/decline.py b/packages/meshbay-cli/src/meshbay_cli/commands/decline.py new file mode 100644 index 0000000..23c7bb8 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/decline.py @@ -0,0 +1,43 @@ +"""`decline`: answer an invitation no. Asked first; `--yes` answers for a script.""" + +import json + +from ..client.hub import HubError +from ..client.paths import handle +from ..errors import NOT_FOUND, CliError +from ..output import confirm, safe, sentence +from .context import Context +from .invitations import find_invitation + +HELP = "decline an invitation" + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("decline", parents=[parent], help=HELP, description=sentence(HELP) + + ". Only a new invitation from the group's owner brings it back.") + p.add_argument("invitation", metavar="INVITATION", + help="the group, as `invitations` lists it") + p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation") + + +async def run(ctx: Context) -> int: + await ctx.hub.check_version() + account = ctx.account() + try: + group = find_invitation(await account.call(account.hub.invitations), + ctx.args.invitation) + name = handle(group) + confirm(f"Decline the invitation to {safe(name)}?", ctx.args.yes) + try: + await account.call(account.hub.decline_invitation, group["id"]) + except HubError as e: + if e.status == 404: + raise CliError(NOT_FOUND, "That invitation is no longer waiting.") from e + raise + finally: + await account.close() + if ctx.args.json: + print(json.dumps({"declined": True, "path": name, "id": group["id"]})) + else: + print(f"Declined the invitation to {safe(name)}.") + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/devices.py b/packages/meshbay-cli/src/meshbay_cli/commands/devices.py new file mode 100644 index 0000000..9fab241 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/devices.py @@ -0,0 +1,96 @@ +""" +`devices`: this account's devices on the node serving a group — what the +application's "Your devices on this node" shows and does. + +Identities are per node, so a group names the node. `approve` admits the device +showing a code; `revoke` retires one, asked first (`--yes` for a script). A node +where this machine is the only linked device admits no other without this. +""" + +import json +import sys + +from ..client import devices +from ..client.groups import my_groups, open_group +from ..client.paths import group_arg, handle, resolve_group +from ..errors import REFUSED, USAGE, CliError +from ..output import confirm, err, safe, sentence +from .context import Context + +HELP = "your devices on the node serving a group: list, approve a new one, revoke one" + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("devices", parents=[parent], help=HELP, description=sentence(HELP) + + ". A new device shows a code; approve it here, from a machine " + "already linked to that node.") + p.add_argument("verb", choices=("list", "approve", "revoke")) + p.add_argument("group", metavar="GROUP", help="the group, as `groups` lists it") + p.add_argument("arg", nargs="?", metavar="CODE|DEVICE", + help="approve: the code the new device shows; revoke: the device, by " + "the first characters of its key as `devices list` shows it") + p.add_argument("-y", "--yes", action="store_true", + help="revoke without asking for confirmation") + + +def _when(iso: str) -> str: + return (iso or "")[:16].replace("T", " ") + + +async def run(ctx: Context) -> int: + a = ctx.args + if a.verb != "list" and not a.arg: + raise CliError(USAGE, f"Name the {'code' if a.verb == 'approve' else 'device'}: " + f"meshbay-cli devices {a.verb} GROUP " + f"{'CODE' if a.verb == 'approve' else 'DEVICE'}") + await ctx.hub.check_version() + account = ctx.account() + session = None + try: + group = resolve_group(await my_groups(account), group_arg(a.group)) + name = handle(group) + session = await open_group(account, group) + if a.verb == "list": + return _print(ctx, name, await devices.list_devices(session)) + if a.verb == "approve": + pk = await devices.approve(session, account, a.arg) + print(json.dumps({"approved": pk}) if a.json else + f"Approved the device {pk[:16]} on the node serving {safe(name)}.") + return 0 + device = devices.find_device((await devices.list_devices(session))["devices"], + a.arg) + if device.get("is_this_one"): + raise CliError(REFUSED, "That is this machine. Revoke it from another of your " + "devices.") + label = device.get("label") or "no label" + confirm(f"Revoke the device {device['pk_ed25519'][:16]} ({safe(label)}) on the " + f"node serving {safe(name)}? It will no longer open this node's groups.", + a.yes) + await devices.revoke(session, account, device["pk_ed25519"]) + print(json.dumps({"revoked": device["pk_ed25519"]}) if a.json else + f"Revoked the device {device['pk_ed25519'][:16]}.") + return 0 + finally: + if session is not None: + await session.close() + await account.close() + + +def _print(ctx: Context, name: str, listed: dict) -> int: + rows = listed["devices"] + if ctx.args.json: + json.dump(listed, sys.stdout, indent=1) + print() + return 0 + print(f" {'KEY':<18}{'ADDED':<18}{'VIA':<12}LABEL") + for d in rows: + mark = "*" if d.get("is_this_one") else " " + print(f"{mark} {d['pk_ed25519'][:16]:<18}{_when(d.get('pinned_at')):<18}" + f"{safe(d.get('pinned_via') or ''):<12}{safe(d.get('label') or '')}") + if not ctx.args.quiet: + err("* this machine") + if listed["pending"]: + n = listed["pending"] + err(f"{n} device{'s' if n != 1 else ''} waiting for approval: " + f"meshbay-cli devices approve {name} CODE") + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/groups.py b/packages/meshbay-cli/src/meshbay_cli/commands/groups.py index 7595e2b..04395dd 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/groups.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/groups.py @@ -7,12 +7,15 @@ it. "owner" is the account that created the group on the hub — a node's operator is another notion. """ +import contextlib import json import sys from datetime import datetime from ..client.groups import my_groups +from ..client.hub import HubError from ..client.paths import handle +from ..errors import CliError from ..output import safe, sentence from .context import Context @@ -31,7 +34,7 @@ def node_state(g: dict) -> str: return "up" if g.get("node_online") else "down" -def _when(iso: str | None) -> str: +def local_time(iso: str | None) -> str: if not iso: return "" try: @@ -45,6 +48,11 @@ async def run(ctx: Context) -> int: account = ctx.account() try: groups = await my_groups(account) + waiting = 0 + if not ctx.args.json and not ctx.args.quiet: + # A hub without invitations answers nothing useful; the list stands. + with contextlib.suppress(HubError, CliError): + waiting = len(await account.call(account.hub.invitations)) finally: await account.close() groups.sort(key=lambda g: (g["name"].lower(), g.get("owner_username") or "")) @@ -58,6 +66,9 @@ async def run(ctx: Context) -> int: for g in groups], sys.stdout, indent=1) print() return 0 + if waiting: + print(f"{waiting} invitation{'s' if waiting != 1 else ''} waiting for your answer: " + "meshbay-cli invitations", file=sys.stderr) if not groups: print("You belong to no group yet.", file=sys.stderr) return 0 @@ -65,5 +76,5 @@ async def run(ctx: Context) -> int: for g in groups: role = "owner" if g.get("is_admin") else "member" print(f"{node_state(g):<6}{role:<8}{safe(g.get('visibility') or ''):<12}" - f"{_when(g.get('last_activity_at')):<18}{safe(handle(g))}") + f"{local_time(g.get('last_activity_at')):<18}{safe(handle(g))}") return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/invitations.py b/packages/meshbay-cli/src/meshbay_cli/commands/invitations.py new file mode 100644 index 0000000..df2a23e --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/invitations.py @@ -0,0 +1,66 @@ +""" +`invitations`: the groups somebody added this account to, waiting for an answer. + +Until it is accepted an invitation is not a membership: the group is not in +`groups`, and its nodes cannot be reached. `join` accepts one, `decline` refuses +it. +""" + +import json +import sys + +from ..client.paths import group_arg, handle, resolve_group +from ..errors import NOT_FOUND, CliError +from ..output import safe, sentence +from .context import Context +from .groups import local_time + +HELP = "list the invitations waiting for your answer" + + +def add_parser(sub, parent) -> None: + sub.add_parser("invitations", parents=[parent], help=HELP, description=sentence(HELP) + + ". The GROUP column is what `join` and `decline` take.") + + +def as_group(inv: dict) -> dict: + """An invitation row in the shape `groups` rows have, for `handle` and lookups.""" + return {"id": inv["group_id"], "name": inv["name"], + "owner_username": inv.get("owner_username") or "", "invitation": inv} + + +def find_invitation(invitations: list[dict], spec: str) -> dict: + try: + return resolve_group([as_group(i) for i in invitations], group_arg(spec)) + except CliError as e: + if e.code != NOT_FOUND: + raise + raise CliError(NOT_FOUND, f"No invitation to a group called {spec!r} is " + "waiting; `meshbay-cli invitations` lists them.") from None + + +async def run(ctx: Context) -> int: + await ctx.hub.check_version() + account = ctx.account() + try: + invitations = await account.call(account.hub.invitations) + finally: + await account.close() + rows = sorted((as_group(i) for i in invitations), + key=lambda g: (g["name"].lower(), g["owner_username"])) + if ctx.args.json: + json.dump([{"group": g["name"], "owner": g["owner_username"], "id": g["id"], + "path": handle(g), "invited_by": g["invitation"].get("invited_by") or "", + "created_at": g["invitation"].get("created_at")} + for g in rows], sys.stdout, indent=1) + print() + return 0 + if not rows: + print("No invitation is waiting.", file=sys.stderr) + return 0 + print(f"{'INVITED BY':<22}{'DATE':<18}GROUP") + for g in rows: + inv = g["invitation"] + print(f"{safe(inv.get('invited_by') or ''):<22}{local_time(inv.get('created_at')):<18}" + f"{safe(handle(g))}") + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/join.py b/packages/meshbay-cli/src/meshbay_cli/commands/join.py new file mode 100644 index 0000000..ee75c96 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/join.py @@ -0,0 +1,139 @@ +""" +`join`: become a member of a group, and be admitted by its node. + +Two halves, as in the application (docs/MESHBAY_DESIGN.md §3.4): the hub makes +the account a member — an invitation accepted, or an invitation link redeemed — +and the node admits it, wrapping the group key for a key pair made here and +kept. The node is dialled at once, so the group can be used as soon as this +returns. A link's code goes to the node the link names and to no other; a node +that is away gets the code at the next connection, kept until then. +""" + +import json +import sys + +from ..client.groups import my_groups, open_group +from ..client.hub import HubError +from ..client.invite import parse_link +from ..client.paths import group_arg, handle, resolve_group +from ..client.session import DeviceLinkPending, NodeRefusal +from ..errors import AUTH, NODE, NOT_FOUND, USAGE, CliError +from ..output import err, safe, sentence +from .context import Context +from .invitations import find_invitation + +HELP = "join a group: accept an invitation, or use an invitation link" + +BROWSER_HINT = ("A browser will not open {group} with your passphrase: this machine " + "leaves no copy of your keys on its node. To leave one, as the " + "application does with Browser access on: meshbay-cli browser-access on") + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("join", parents=[parent], help=HELP, description=sentence(HELP) + + ". The group's node is reached at once and admits this " + "machine with its own keys.") + p.add_argument("target", metavar="INVITATION|LINK", + help="an invitation as `invitations` lists it, an invitation link as " + "it was sent, or a group of yours whose node asked for a code") + p.add_argument("--code", help="the invitation's code (XXXX-XXXX), when the node asks " + "for one") + + +async def _membership(ctx: Context, account, target: str) -> tuple[str, str | None, + str | None]: + """The hub's half: the group's id, and the code and node a link carries.""" + link = parse_link(target) + if link is not None: + if link.origin and link.origin.rstrip("/") != ctx.hub_url: + raise CliError(USAGE, f"This link was made on {link.origin}; run the command " + f"with --hub {link.origin}.") + try: + preview = await account.call(account.hub.preview_link, link.ticket) + if not preview.get("already_member"): + await account.call(account.hub.redeem_link, link.ticket) + except HubError as e: + if e.status in (403, 404): + raise CliError(NOT_FOUND, "This invitation link no longer works: used by " + "another account, cancelled, or expired. Ask " + "for a new one.") from e + raise + if not ctx.args.quiet and not ctx.args.json: + err(f"Joining {safe(preview.get('group_name') or '')}, invited by " + f"{safe(preview.get('inviter') or '?')}.") + return link.group_id, link.code, link.node_pk + + invitations = await account.call(account.hub.invitations) + try: + group = find_invitation(invitations, target) + except CliError as e: + if e.code != NOT_FOUND: + raise + # Already a member: only the node's half is left to do. + group = resolve_group(await my_groups(account), group_arg(target)) + return group["id"], None, None + try: + await account.call(account.hub.accept_invitation, group["id"]) + except HubError as e: + if e.status == 404: + raise CliError(NOT_FOUND, "That invitation is no longer waiting.") from e + raise + return group["id"], None, None + + +async def run(ctx: Context) -> int: + a = ctx.args + await ctx.hub.check_version() + account = ctx.account() + if not account.remembered: + raise CliError(AUTH, "Joining needs a saved session: the keys made for the group " + "are kept on this machine. Run `meshbay-cli login` first.") + group_id, link_code, node_pk = await _membership(ctx, account, a.target) + group = next((g for g in await my_groups(account) if g["id"] == group_id), None) + if group is None: + raise CliError(NOT_FOUND, "The hub did not make this account a member.") + name = handle(group) + code = (a.code or link_code or "").strip().upper() or None + + while True: + try: + session = await open_group(account, group, join_code=code, node_pk=node_pk) + break + except NodeRefusal as e: + if e.refusal_code != "code_required" or code: + raise + if not sys.stdin.isatty(): + raise CliError(NODE, f"You are a member of {safe(name)}; its node has not " + "admitted you yet and needs your invitation's code:\n" + f" meshbay-cli join {name} --code CODE") from e + sys.stderr.write(f"The node asks for your invitation's code to {name} " + "(XXXX-XXXX): ") + sys.stderr.flush() + code = sys.stdin.readline().strip().upper() or None + if not code: + raise CliError(USAGE, "No code given.") from e + except CliError as e: + if e.code != NODE or not code or isinstance(e, NodeRefusal | DeviceLinkPending): + raise + await account.keep_join_code(group_id, code, node_pk) + raise CliError(NODE, f"You are a member of {safe(name)}, but its node cannot " + f"be reached now ({e.message}). The code is kept and " + "sent at the next connection, such as:\n" + f" meshbay-cli ls {name}/") from e + await session.close() + + if a.json: + print(json.dumps({"joined": True, "group": group["name"], + "owner": group.get("owner_username"), "id": group_id, + "path": name})) + else: + print(f"Joined {safe(name)}. Its files: meshbay-cli ls {safe(name)}/") + if account.browser_access is None and not a.quiet: + try: + prefs = await account.call(account.hub.preferences) + except (HubError, CliError): + prefs = {} + # The hub's word can only produce this line, never a copy of the keys. + if prefs.get("browser_access") != "off": + err(BROWSER_HINT.format(group=safe(name))) + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/leave.py b/packages/meshbay-cli/src/meshbay_cli/commands/leave.py new file mode 100644 index 0000000..e9a529e --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/leave.py @@ -0,0 +1,55 @@ +""" +`leave`: give up a membership. Asked first; `--yes` answers for a script. + +The hub's half only, as in the application: the account stops being a member, +so its nodes can no longer be reached. The node keeps what it holds — the +identity it pinned, its copy of the keys, the files uploaded — until its +operator unpins them, and the group key already served opens what it opened +until the operator rotates it. The owner cannot leave a group. +""" + +import json + +from ..client.groups import my_groups +from ..client.hub import HubError +from ..client.paths import group_arg, handle, resolve_group +from ..errors import REFUSED, CliError +from ..output import confirm, safe, sentence +from .context import Context + +HELP = "leave a group" + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("leave", parents=[parent], help=HELP, description=sentence(HELP) + + ". Its files and its chat are no longer reachable from this " + "account; only a new invitation brings it back. The owner cannot " + "leave a group.") + p.add_argument("group", metavar="GROUP", help="the group, as `groups` lists it") + p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation") + + +async def run(ctx: Context) -> int: + await ctx.hub.check_version() + account = ctx.account() + try: + group = resolve_group(await my_groups(account), group_arg(ctx.args.group)) + name = handle(group) + if group.get("is_admin"): + raise CliError(REFUSED, f"You own {safe(name)}, so you cannot leave it: it " + "would be left with nobody able to manage it.") + confirm(f"Leave {safe(name)}? Its files and chat will no longer be reachable " + "from your account.", ctx.args.yes) + try: + await account.call(account.hub.leave_group, group["id"]) + except HubError as e: + if e.status == 409: + raise CliError(REFUSED, e.detail) from e + raise + finally: + await account.close() + if ctx.args.json: + print(json.dumps({"left": True, "path": name, "id": group["id"]})) + else: + print(f"Left {safe(name)}.") + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/manpage.py b/packages/meshbay-cli/src/meshbay_cli/manpage.py index 0633904..7ee3d33 100644 --- a/packages/meshbay-cli/src/meshbay_cli/manpage.py +++ b/packages/meshbay-cli/src/meshbay_cli/manpage.py @@ -15,29 +15,36 @@ from .__main__ import ACTIONS, build_parser EXIT_CODES = { 0: "Success.", - errors.USAGE: "Usage error: an unknown option, a missing argument.", + errors.USAGE: "Usage error: an unknown option, a missing argument. Also: an action " + "that asks for confirmation, run with no terminal and without " + "\\fB\\-\\-yes\\fR, or not confirmed.", errors.AUTH: "Sign-in refused, no stored session and no passphrase to sign in " "with, or the account is locked out.", - errors.NOT_FOUND: "A group, a path, or a pattern that matches nothing.", + errors.NOT_FOUND: "A group, an invitation, a path, a device or a pattern that " + "matches nothing; an invitation link no longer valid; a linking " + "code no device is waiting with, or several are.", errors.PARTIAL: "Some items failed; the others were done. Each failure is " "named on stderr.", errors.NODE: "No node serving the group could be reached, or the node refused " "the connection. Also: this machine is waiting for device " - "approval (the code is on stderr).", + "approval (the code is on stderr), or the node asks for an " + "invitation's code.", errors.NODE_KEY: "A node presented another key than the one pinned at first " "contact. Nothing was sent. See \\fBtrust\\fR.", errors.VERSION: "This client, or the protocol it speaks, is too old or too new " "for the hub or the node.", errors.REFUSED: "Refused before anything was sent: a name already taken or not " - "accepted by the node, a folder read-only or unavailable.", + "accepted by the node, a folder read-only or unavailable, leaving a " + "group you own, revoking this machine.", errors.INTERRUPTED: "Interrupted (Ctrl+C). A download resumes where it stopped " "on the next run.", } DESCRIPTION = r""" .B meshbay-cli -is a member's view of their MeshBay groups from a terminal: sign in, list the -groups and their folders, download and upload files. +is a member's view of their MeshBay groups from a terminal: sign in, join and +leave groups, list them and their folders, download and upload files, 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. @@ -159,7 +166,10 @@ The first time this machine opens a group whose node holds no copy of your keys (the application's "Browser access" turned off), it asks to be linked as a new device and prints a code. Approve that code from the application on a device already linked (the -group's Settings); with a terminal, +group's Settings), or with +.B meshbay-cli devices approve +.I GROUP CODE +on a machine already linked; with a terminal, .B meshbay-cli waits and goes on by itself, otherwise it exits with status 5 and the next run goes on once the code is approved. @@ -171,6 +181,46 @@ A node presenting another key is refused, with status 6, until forgets the pin; do that only for a key change the node's operator announced. """ +MEMBERSHIP = r""" +Joining has two halves. +The hub makes the account a member: +.B join +accepts an invitation listed by +.BR invitations , +or redeems an invitation link pasted as it was sent. +Then the group's node admits it: a key pair is made on this machine, the node +wraps the group key for it, and it is kept (see FILES), so +.B join +needs a saved session. +A link carries the code for the node it names, and is sent to that node only. +An invitation's code is given with +.BR \-\-code , +or typed when the node asks for it; a node that cannot be reached gets it at the +next connection. +.PP +.B leave +ends a membership on the hub. +The node keeps what it holds (the key it pinned, its copy of your keys, the +files you uploaded) until its operator removes them, and the group key already +served still opens what it opened until the operator replaces it. +The owner of a group cannot leave it. +.PP +.B devices +lists this account's devices on the node serving a group, approves a new one by +the code it shows, and revokes one. +Exactly one waiting device must match a code: a code that matches several, which +somebody who saw it on your screen could cause, approves none. +.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; +.B off +withdraws that copy. +Never set, nothing is written or withdrawn. +It is this machine's setting, as Browser access is the desktop application's: +the hub keeps a copy for browsers to read, and that copy never turns it on here. +""" + FILES = r""" .TP .I $XDG_DATA_HOME/meshbay\-cli/ \fR(default \fI~/.local/share/meshbay\-cli/\fR) @@ -178,9 +228,9 @@ On Windows, .IR %LOCALAPPDATA%\emeshbay\-cli\e . Holds .B state.json -(the hub, the account name, pinned node keys), +(the hub, the account name, pinned node keys, the browser access setting), .B secrets.enc -(the session and keys, encrypted), +(the session, the keys, invitation codes waiting for their node; encrypted), .B store.key only where the system has no key storage, and .B interactive.log @@ -222,6 +272,9 @@ The interactive mode draws without colour. EXAMPLES = r""" .EX meshbay\-cli login \-u alice +meshbay\-cli invitations +meshbay\-cli join Family@bob \-\-code K7P2\-9WQX +meshbay\-cli join 'https://meshbay.org/#/invite?v=1&g=...' meshbay\-cli groups meshbay\-cli ls \-l Family/photos/2024 meshbay\-cli get \-R Family/photos/2024 \-o ~/Pictures/ @@ -229,6 +282,8 @@ meshbay\-cli get 'Family/music/*.flac' meshbay\-cli put \-R ~/scans Family/documents tar c notes | meshbay\-cli put \- Family/documents/notes.tar meshbay\-cli ls \-\-json \-R / | jq '.[] | .path' +meshbay\-cli devices approve Family@bob ZX4P\-7QRT +meshbay\-cli leave \-\-yes Club@carol .EE """ @@ -326,6 +381,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, 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/src/meshbay_cli/output.py b/packages/meshbay-cli/src/meshbay_cli/output.py index 75a54ff..d4c0758 100644 --- a/packages/meshbay-cli/src/meshbay_cli/output.py +++ b/packages/meshbay-cli/src/meshbay_cli/output.py @@ -138,3 +138,23 @@ class Progress: def sentence(text: str) -> str: """The first letter up, the rest untouched: `str.capitalize` turns -R into -r.""" return text[:1].upper() + text[1:] + + +def confirm(question: str, yes: bool) -> None: + """ + Go on only on a yes: `--yes`, or `y` typed at a terminal. With neither — a + script that did not say so — refuse before anything is sent, rather than + take silence for consent. + """ + from .errors import USAGE, CliError + if yes: + return + if not sys.stdin.isatty(): + raise CliError(USAGE, f"{question} Not done: there is no terminal to ask on; " + "pass --yes to confirm.") + # On stderr, like every other word to the person: stdout is the result. + sys.stderr.write(f"{question} [y/N] ") + sys.stderr.flush() + answer = sys.stdin.readline() + if answer.strip().lower() not in ("y", "yes"): + raise CliError(USAGE, "Not done.") diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/screens.py b/packages/meshbay-cli/src/meshbay_cli/tui/screens.py index 9d19431..e1ab620 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/screens.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/screens.py @@ -349,6 +349,8 @@ class DeviceCode: f"This machine is not yet a device of yours on the node hosting " f"{safe(handle(group))}. Approve it from the MeshBay application or a " f"browser: the group's Settings → \"Your devices on this node\" → " - f"enter {pending.link_code}. Waiting…", + f"enter {pending.link_code}; or on a machine already linked: " + f"meshbay-cli devices approve {safe(handle(group))} {pending.link_code}. " + "Waiting…", title="Approve this machine", timeout=600, markup=False) return True diff --git a/packages/meshbay-cli/tests/fake_hub.py b/packages/meshbay-cli/tests/fake_hub.py index ec1a0c0..15cbdf7 100644 --- a/packages/meshbay-cli/tests/fake_hub.py +++ b/packages/meshbay-cli/tests/fake_hub.py @@ -34,6 +34,9 @@ class FakeHub: self.refresh_delay = 0.0 self.groups = [{"id": "g1", "name": "Family", "owner_username": USERNAME, "node_online": True, "visibility": "private", "is_admin": True}] + self.pending: list[dict] = [] + self.links: dict[str, dict] = {} + self.prefs: dict[str, str] = {} def _session(self, family: str | None = None) -> dict: family = family or secrets.token_hex(4) @@ -106,3 +109,70 @@ class FakeHub: async def my_groups(self, token): self._auth(token) return self.groups + + # ── Joining and leaving ────────────────────────────────────────────────── + # + # `pending` holds invitations (group rows with `invited_by`); `links` maps a + # ticket to the group it admits to, and is spent by the first redeem. + + def _group_row(self, g: dict) -> dict: + return {k: v for k, v in g.items() if k not in ("invited_by", "created_at")} + + async def invitations(self, token): + self._auth(token) + return [{"group_id": g["id"], "name": g["name"], + "owner_username": g["owner_username"], "invited_by": g["invited_by"], + "created_at": "2026-10-14T09:30:00+00:00"} for g in self.pending] + + async def accept_invitation(self, token, group_id): + self._auth(token) + found = [g for g in self.pending if g["id"] == group_id] + if not found: + raise HubError(404, "No such invitation") + self.pending.remove(found[0]) + self.groups.append(self._group_row(found[0])) + return {"status": "joined", "group_id": group_id} + + async def decline_invitation(self, token, group_id): + self._auth(token) + found = [g for g in self.pending if g["id"] == group_id] + if not found: + raise HubError(404, "No such invitation") + self.pending.remove(found[0]) + return {"status": "declined", "group_id": group_id} + + async def preview_link(self, token, ticket): + self._auth(token) + g = self.links.get(ticket) + if g is None: + raise HubError(404, "invite_not_valid") + return {"group_id": g["id"], "group_name": g["name"], "inviter": "bob-test", + "expires_at": "2026-10-21T00:00:00+00:00", + "already_member": any(m["id"] == g["id"] for m in self.groups)} + + async def redeem_link(self, token, ticket): + self._auth(token) + g = self.links.pop(ticket, None) + if g is None: + raise HubError(404, "invite_not_valid") + self.groups.append(self._group_row(g)) + return {"group_id": g["id"], "group_name": g["name"]} + + async def leave_group(self, token, group_id): + self._auth(token) + g = next((g for g in self.groups if g["id"] == group_id), None) + if g is None: + raise HubError(404, "You are not a member of this group") + if g.get("is_admin"): + raise HubError(409, "You own this group, so you cannot leave it") + self.groups.remove(g) + return {"status": "left", "group_id": group_id} + + async def preferences(self, token): + self._auth(token) + return dict(self.prefs) + + async def set_preference(self, token, key, value): + self._auth(token) + self.prefs[key] = value + return {"key": key, "value": value} diff --git a/packages/meshbay-cli/tests/fake_node.py b/packages/meshbay-cli/tests/fake_node.py index e733c56..f53eb01 100644 --- a/packages/meshbay-cli/tests/fake_node.py +++ b/packages/meshbay-cli/tests/fake_node.py @@ -9,16 +9,30 @@ the node's code: a node the CLI must work against. """ import asyncio +import base64 import hashlib import math import secrets +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +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.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.protocol import UPLOAD_PROBE_INDEX, file_upload_ack_wire from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes GROUP_ID = "g1" +NODE_PK = base64.b64encode(b"\x05" * 32).decode() CHUNK = 1024 * 1024 @@ -35,6 +49,14 @@ class FakeNode: self.refuse_upload_code = "" self.chunk_requests = 0 self.chunk_delay = 0.0 + # This machine's key here, the account's other devices, and the requests + # waiting for an approval — each `{pk_ed25519, pk_x25519, code_hash}`. + self.identity = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()) + self.nonce = secrets.token_bytes(32) + self.devices = [{"pk_ed25519": self.identity.pk_ed, "pk_x25519": self.identity.pk_x, + "label": "", "pinned_at": "2026-10-01T10:00:00+00:00", + "pinned_via": "code"}] + self.requests: list[dict] = [] def add(self, path: str, name: str, data: bytes) -> dict: fid = hashlib.blake2b(data + path.encode() + name.encode(), digest_size=32).hexdigest() @@ -47,7 +69,17 @@ class FakeNode: return entry def session(self) -> GroupSession: - return GroupSession(None, FakeChannel(self), GROUP_ID, "node-1", "pk", self.gek, {}) + return GroupSession(None, FakeChannel(self), GROUP_ID, "node-1", NODE_PK, self.gek, + {}, self.identity, self.nonce) + + 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()) + self.requests.append({"pk_ed25519": other.pk_ed, "pk_x25519": other.pk_x, + "created_at": "2026-10-14T10:00:00+00:00", + "code_hash": device_code_hash(code.replace("-", ""), + other.pk_ed, other.pk_x)}) + return other def stored(self, path: str) -> bytes | None: parent, _, name = path.rpartition("/") @@ -162,5 +194,57 @@ class FakeChannel: dir=folder) + # ── Devices ────────────────────────────────────────────────────────────── + + def _device_list(self, msg): + n = self.node + return {"type": "device_list_result", "pending": len(n.requests), + "devices": [{**d, "is_this_one": d["pk_ed25519"] == n.identity.pk_ed} + for d in n.devices]} + + def _device_lookup(self, msg): + return {"type": "device_lookup_result", "requests": list(self.node.requests)} + + def _device_add(self, msg): + n = self.node + transcript = device_add_transcript( + node_pk_b64=NODE_PK, user_id=USER_ID, pk_ed25519_b64=msg["pk_ed25519"], + pk_x25519_b64=msg["pk_x25519"], nonce_node=n.nonce, ts=msg["ts"]) + if not _signed_by_a_device(n, transcript, msg["sig"]): + return {"type": "error", "detail": "Not signed by a device already paired here"} + req = next((r for r in n.requests if r["code_hash"] == msg["code_hash"] + and r["pk_ed25519"] == msg["pk_ed25519"]), None) + if req is None: + return {"type": "error", "detail": "No pending request"} + n.requests.remove(req) + n.devices.append({"pk_ed25519": req["pk_ed25519"], "pk_x25519": req["pk_x25519"], + "label": "", "pinned_at": "2026-10-14T10:01:00+00:00", + "pinned_via": "device"}) + return {"type": "device_add_ack", "pk_ed25519": req["pk_ed25519"]} + + def _device_revoke(self, msg): + n = self.node + transcript = device_revoke_transcript( + node_pk_b64=NODE_PK, user_id=USER_ID, pk_ed25519_b64=msg["pk_ed25519"], + nonce_node=n.nonce, ts=msg["ts"]) + if not _signed_by_a_device(n, transcript, msg["sig"]): + return {"type": "error", "detail": "Not signed by a device already paired here"} + if len(n.devices) <= 1: + return {"type": "error", "detail": "This is your only device here"} + n.devices = [d for d in n.devices if d["pk_ed25519"] != msg["pk_ed25519"]] + return {"type": "device_add_ack", "revoked": msg["pk_ed25519"]} + + +def _signed_by_a_device(node: FakeNode, transcript: bytes, sig: str) -> bool: + for d in node.devices: + try: + Ed25519PublicKey.from_public_bytes(base64.b64decode(d["pk_ed25519"])).verify( + base64.b64decode(sig), transcript) + return True + except Exception: + continue + return False + + def chunks_of(size: int, chunk: int) -> int: return max(1, math.ceil(size / chunk)) diff --git a/packages/meshbay-cli/tests/test_cli_actions.py b/packages/meshbay-cli/tests/test_cli_actions.py index b88d93c..4fffc4e 100644 --- a/packages/meshbay-cli/tests/test_cli_actions.py +++ b/packages/meshbay-cli/tests/test_cli_actions.py @@ -233,6 +233,198 @@ def test_groups_lists_each_group_with_its_state(hub, capsys): assert rows[0]["path"] == "Club%2FPhotos@bob-test" +class _Terminal(io.StringIO): + """stdin at a terminal, with the answers a person types.""" + + def isatty(self): + return True + + +def _signed_in(hub, capsys): + assert main("--hub", hub.url, "-u", USERNAME, "--password", PASSPHRASE, "login") == 0 + capsys.readouterr() + + +CLUB = {"id": "g2", "name": "Club/Photos", "owner_username": "bob-test", + "node_online": True, "visibility": "private", "is_admin": False, "hosted": True} + + +@pytest.fixture +def joined(monkeypatch): + """`join`'s node half, recorded: the code and the node each try was given.""" + from meshbay_cli.client.session import NodeRefusal + from meshbay_cli.commands import join + tries = [] + state = {"needs_code": False, "away": False} + + async def open_group(account, group, *, join_code=None, node_pk=None): + tries.append({"group": group["id"], "code": join_code, "node_pk": node_pk}) + if state["away"]: + from meshbay_cli.errors import NODE, CliError + raise CliError(NODE, "No node serving it is online.") + if state["needs_code"] and not join_code: + raise NodeRefusal("this node has not admitted you yet", "code_required") + return FakeSession() + monkeypatch.setattr(join, "open_group", open_group) + return tries, state + + +def test_an_invitation_is_listed_joined_and_named_in_groups(hub, joined, capsys): + tries, _ = joined + hub.pending.append({**CLUB, "invited_by": "bob-test"}) + _signed_in(hub, capsys) + + assert main("groups") == 0 + assert "1 invitation waiting for your answer" in capsys.readouterr().err + assert main("invitations") == 0 + header, line = capsys.readouterr().out.splitlines() + assert header.split() == ["INVITED", "BY", "DATE", "GROUP"] + assert line.startswith("bob-test") and line.endswith("Club%2FPhotos@bob-test") + + assert main("join", "Club%2FPhotos") == 0 + out = capsys.readouterr() + assert "Joined Club%2FPhotos@bob-test" in out.out + assert tries == [{"group": "g2", "code": None, "node_pk": None}] + # The hub keeps no word on browser access: said once, nothing done. + assert "meshbay-cli browser-access on" in out.err + assert [g["id"] for g in hub.groups] == ["g1", "g2"] and not hub.pending + + assert main("invitations") == 0 + assert "No invitation is waiting" in capsys.readouterr().err + assert main("join", "Nothing") == 3 + + +def test_a_node_that_asks_for_a_code_gets_it_from_the_option_or_the_terminal( + hub, joined, monkeypatch, capsys): + tries, state = joined + state["needs_code"] = True + hub.pending.append({**CLUB, "invited_by": "bob-test"}) + _signed_in(hub, capsys) + + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + assert main("join", "Club%2FPhotos") == 5 # a member now, not admitted + err = capsys.readouterr().err + assert "join Club%2FPhotos@bob-test --code CODE" in err + + assert main("join", "Club%2FPhotos", "--code", "k7p2-9wqx") == 0 + assert tries[-1]["code"] == "K7P2-9WQX" + + monkeypatch.setattr("sys.stdin", _Terminal("ab12-cd34\n")) + assert main("join", "Club%2FPhotos@bob-test") == 0 + assert tries[-1]["code"] == "AB12-CD34" + + +def test_an_invitation_link_goes_to_its_hub_and_its_code_to_its_node(hub, joined, capsys): + tries, state = joined + ticket, node = "AbCdEfGhIjKlMnOpQr-_12", "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + link = (f"{hub.url}/#/invite?v=1&g=0f8fad5b-d9cb-469f-a165-70867728950e&t={ticket}" + f"&n={node}&c=k7p2-9wqx") + hub.links[ticket] = {**CLUB, "id": "0f8fad5b-d9cb-469f-a165-70867728950e"} + _signed_in(hub, capsys) + + assert main("join", link.replace(hub.url, "https://elsewhere.test")) == 1 + assert "--hub https://elsewhere.test" in capsys.readouterr().err + + state["away"] = True # redeemed, but the node is not there + assert main("join", link) == 5 + assert "The code is kept" in capsys.readouterr().err + assert tries[-1] == {"group": "0f8fad5b-d9cb-469f-a165-70867728950e", + "code": "K7P2-9WQX", + "node_pk": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="} + from meshbay_cli.client.store import Store + store = Store() + store.load() + assert store.secrets["join_codes"]["0f8fad5b-d9cb-469f-a165-70867728950e"] == { + "code": "K7P2-9WQX", "node_pk": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="} + + assert main("join", link) == 3 # spent: the hub says so + assert "no longer works" in capsys.readouterr().err + + +def test_declining_and_leaving_ask_first(hub, monkeypatch, capsys): + hub.pending.append({**CLUB, "invited_by": "bob-test"}) + hub.groups.append({**CLUB, "id": "g3", "name": "Choir"}) + _signed_in(hub, capsys) + + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + assert main("decline", "Club%2FPhotos") == 1 # no terminal, no --yes + assert "pass --yes" in capsys.readouterr().err and hub.pending + assert main("leave", "Choir") == 1 + assert any(g["id"] == "g3" for g in hub.groups) + assert main("leave", "Family") == 8 # the owner, before any question + assert "You own" in capsys.readouterr().err + + monkeypatch.setattr("sys.stdin", _Terminal("n\n")) + assert main("leave", "Choir") == 1 + assert any(g["id"] == "g3" for g in hub.groups) + monkeypatch.setattr("sys.stdin", _Terminal("y\n")) + assert main("leave", "Choir") == 0 + assert "Left Choir@bob-test" in capsys.readouterr().out + assert not any(g["id"] == "g3" for g in hub.groups) + + assert main("decline", "--yes", "Club%2FPhotos") == 0 + assert not hub.pending and not any(g["id"] == "g2" for g in hub.groups) + + +def test_devices_are_listed_approved_by_code_and_revoked(node, monkeypatch, capsys): + from meshbay_cli.client.groups import open_group # noqa: F401 (patched below) + from meshbay_cli.commands import devices + + async def open_group_(account, group): + return node.session() + monkeypatch.setattr(devices, "open_group", open_group_) + + other = node.request_device("ZX4P-7QRT") + assert main("devices", "list", "Family") == 0 + out = capsys.readouterr() + assert out.out.splitlines()[1].startswith(f"* {node.identity.pk_ed[:16]}") + assert "1 device waiting for approval" in out.err + + assert main("devices", "approve", "Family", "zx4p-7qrz") == 3 # no request has it + assert main("devices", "approve", "Family", "ZX4P 7QRT") == 0 + assert other.pk_ed in [d["pk_ed25519"] for d in node.devices] + + # The same code filed twice: neither is approved. + node.request_device("AAAA-BBBB") + node.request_device("AAAA-BBBB") + assert main("devices", "approve", "Family", "AAAA-BBBB") == 3 + assert "none was approved" in capsys.readouterr().err + assert len(node.devices) == 2 + + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + assert main("devices", "revoke", "Family", node.identity.pk_ed[:12], "--yes") == 8 + assert main("devices", "revoke", "Family", other.pk_ed[:12]) == 1 # not confirmed + assert main("devices", "revoke", "Family", other.pk_ed[:12], "--yes") == 0 + assert [d["pk_ed25519"] for d in node.devices] == [node.identity.pk_ed] + assert main("devices", "approve", "Family") == 1 + + +def test_browser_access_is_set_here_and_mirrored_never_taken_from_the_hub( + hub, joined, capsys): + hub.prefs["browser_access"] = "on" # anyone with a session can write it + _signed_in(hub, capsys) + assert main("--json", "browser-access") == 0 + assert json.loads(capsys.readouterr().out) == {"this_machine": None, "hub": "on"} + from meshbay_cli.client.store import Store + store = Store() + store.load() + assert "browser_access" not in store.state + + assert main("browser-access", "off") == 0 + assert hub.prefs["browser_access"] == "off" + store.load() + assert store.state["browser_access"] == "off" + # Set here: `join` has nothing to point out. + hub.pending.append({**CLUB, "invited_by": "bob-test"}) + assert main("join", "Club%2FPhotos") == 0 + assert "browser-access" not in capsys.readouterr().err + + # Kept through a new sign-in of the same account. + _signed_in(hub, capsys) + store.load() + assert store.state["browser_access"] == "off" + + 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 e2306d2..9cac392 100644 --- a/packages/meshbay-cli/tests/test_e2e_local.py +++ b/packages/meshbay-cli/tests/test_e2e_local.py @@ -185,7 +185,7 @@ def _build(root: Path, procs: list) -> dict: app = App(hub_url, group_id) asyncio.run(app.first_contact(code)) return {"root": root, "hub_url": hub_url, "group_id": group_id, "db": db, - "share": share, "app": app} + "share": share, "app": app, "run_node": run_node} async def _signed_in(account) -> None: @@ -743,3 +743,108 @@ def test_the_application_links_itself_from_its_group_page(world): assert s["minted"] == 1, "the identity linked is not the one kept" finally: chrome.close() + + + +# ── A second account: invited, joined, its devices, browser access, leaving ── + +BOB = "bob-e2e-cli" +BOB_PASSPHRASE = "another long test passphrase, bob's" + + +def _register(world: dict, username: str, passphrase: str) -> None: + from meshbay_cli.client.keys import auth_key + r = httpx.post(f"{world['hub_url']}/v1/users/register", json={ + "username": username, "email": f"{username}@example.invalid", + "auth_key": auth_key(username, passphrase)}) + assert r.status_code == 201, r.text + with sqlite3.connect(world["db"]) as conn: + conn.execute("UPDATE users SET status='active' WHERE username=?", (username,)) + + +def bob(world: dict, home: str, *args: str) -> subprocess.CompletedProcess: + """`meshbay-cli` as bob, on the machine `home` names; no terminal.""" + return cli(world, *args, home=home) + + +def _bob_login(world: dict, home: str) -> None: + deadline = time.monotonic() + 90 + while True: + r = cli(world, "--hub", world["hub_url"], "-u", BOB, "login", "--password-file", + "-", stdin=BOB_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) # the hub's 10 sign-ins a minute + + +def test_a_member_joins_links_devices_shares_with_browsers_and_leaves(world): + _register(world, BOB, BOB_PASSPHRASE) + group = _handle() + # The owner invites bob on the node, which also tells the hub. + code = re.search(r"INVITATION CODE\s+(\S+)", + world["run_node"]("member", "invite", BOB, "--group", + world["group_id"])).group(1) + + _bob_login(world, "bob1") + r = bob(world, "bob1", "groups") + assert r.returncode == 0 and "1 invitation waiting" in r.stderr, r.stderr + r = bob(world, "bob1", "invitations") + assert r.returncode == 0 and r.stdout.splitlines()[1].endswith(group), r.stdout + + # Accepted on the hub; the node wants the code, and there is no terminal. + r = bob(world, "bob1", "join", group) + assert r.returncode == 5 and "--code CODE" in r.stderr, r.stderr + r = bob(world, "bob1", "join", group, "--code", code) + assert r.returncode == 0, r.stderr + assert "Joined" in r.stdout and "browser-access on" in r.stderr + r = bob(world, "bob1", "ls", f"{group}/share") + assert r.returncode == 0 and "notes.txt" in r.stdout, r.stderr + + # Browser access on: the next connection leaves a copy, which a machine + # with nothing of its own then opens — the same identity, no approval. + assert bob(world, "bob1", "browser-access", "on").returncode == 0 + assert bob(world, "bob1", "ls", group).returncode == 0 + _bob_login(world, "bob2") + r = bob(world, "bob2", "ls", group) + assert r.returncode == 0 and "share/" in r.stdout, r.stderr + r = bob(world, "bob1", "--json", "devices", "list", group) + assert r.returncode == 0, r.stderr + assert len(json.loads(r.stdout)["devices"]) == 1 + + # Off: the copy is withdrawn, so a third machine is a new device, approved + # from the first by its code, and revoked from it. + assert bob(world, "bob1", "browser-access", "off").returncode == 0 + assert bob(world, "bob1", "ls", group).returncode == 0 + _bob_login(world, "bob3") + r = bob(world, "bob3", "ls", group) + assert r.returncode == 5, r.stderr + link_code = re.search(r"enter\s+([0-9A-Z]{4}-[0-9A-Z]{4})", r.stderr).group(1) + assert f"meshbay-cli devices approve {group} {link_code}" in r.stderr + r = bob(world, "bob1", "devices", "approve", group, link_code) + assert r.returncode == 0, r.stderr + assert bob(world, "bob3", "ls", group).returncode == 0 + listed = json.loads(bob(world, "bob1", "--json", "devices", "list", group).stdout) + new = next(d for d in listed["devices"] if not d["is_this_one"]) + assert len(listed["devices"]) == 2 + r = bob(world, "bob1", "devices", "revoke", group, new["pk_ed25519"][:12]) + assert r.returncode == 1 and "--yes" in r.stderr # no terminal to ask on + r = bob(world, "bob1", "devices", "revoke", group, new["pk_ed25519"][:12], "--yes") + assert r.returncode == 0, r.stderr + assert bob(world, "bob3", "ls", group).returncode == 5 # a stranger again + + # Leaving: asked first, then the group is gone from the account. + assert bob(world, "bob1", "leave", group).returncode == 1 + r = bob(world, "bob1", "leave", "--yes", group) + assert r.returncode == 0, r.stderr + assert group not in bob(world, "bob1", "groups").stdout + assert bob(world, "bob1", "ls", group).returncode == 3 + + # Back through an invitation link, pasted as it was sent. + out = world["run_node"]("member", "invite", "--link", "--group", world["group_id"]) + link = re.search(r"INVITATION LINK\s+(\S+)", out).group(1) + r = bob(world, "bob1", "join", link) + assert r.returncode == 0, r.stderr + assert bob(world, "bob1", "ls", f"{group}/share").returncode == 0 + for home in ("bob1", "bob2", "bob3"): + assert bob(world, home, "logout").returncode == 0 diff --git a/packages/meshbay-cli/tests/test_manpage.py b/packages/meshbay-cli/tests/test_manpage.py index ebd71c8..4a43e5d 100644 --- a/packages/meshbay-cli/tests/test_manpage.py +++ b/packages/meshbay-cli/tests/test_manpage.py @@ -34,7 +34,7 @@ def test_every_action_and_option_is_in_its_section(): sub = next(a for a in parser._actions if isinstance(a, argparse._SubParsersAction)) assert list(sub.choices) == list(cli.ACTIONS) for name, sp in sub.choices.items(): - own = _section(page, f".SS {name}") + own = _section(page, f".SS {manpage._roff(name)}") for a in sp._actions: if isinstance(a, argparse._HelpAction): continue diff --git a/packages/meshbay-cli/tests/test_membership.py b/packages/meshbay-cli/tests/test_membership.py new file mode 100644 index 0000000..30fe680 --- /dev/null +++ b/packages/meshbay-cli/tests/test_membership.py @@ -0,0 +1,78 @@ +""" +The pieces `join`, `devices` and `browser-access` stand on, held to the +application's own rules: the invitation link's shape (`static/invite-link.js`), +how a typed code is folded (`crypto.js` `normalizeCode`), and which bundle on a +node is current enough to be left alone. +""" + +import base64 + +import pytest +from meshbay_cli.client.devices import find_device, normalize_code +from meshbay_cli.client.invite import parse_link +from meshbay_cli.client.keys import bundle_is_current, open_bundle, seal_bundle +from meshbay_cli.errors import NOT_FOUND, USAGE, CliError + +GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e" +TICKET = "AbCdEfGhIjKlMnOpQr-_12" +NODE_URLSAFE = base64.urlsafe_b64encode(bytes(range(250, 256)) * 5 + b"\xfe\xff").decode() + + +def _link(**over) -> str: + q = {"v": "1", "g": GROUP, "t": TICKET, "n": NODE_URLSAFE.rstrip("="), "c": "k7p2-9wqx", + **over} + return "https://hub.test/#/invite?" + "&".join(f"{k}={v}" for k, v in q.items()) + + +def test_a_link_gives_its_hub_group_ticket_node_and_code(): + link = parse_link(_link()) + assert (link.origin, link.group_id, link.ticket, link.code) == ( + "https://hub.test", GROUP, TICKET, "K7P2-9WQX") + # The node key as the hub lists nodes: standard base64, padded. + assert base64.b64decode(link.node_pk) == base64.urlsafe_b64decode(NODE_URLSAFE) + assert "+" in link.node_pk or "/" in link.node_pk + # Only the fragment, as somebody may paste it. + assert parse_link(_link().split("#", 1)[1]).origin == "" + + +@pytest.mark.parametrize("bad", [{"v": "2"}, {"g": "not-a-group"}, {"t": "short"}, + {"n": "x" * 42}, {"c": "K7P2-9WQ"}, {"c": "K7P2+9WQX"}]) +def test_a_malformed_link_is_no_link(bad): + assert parse_link(_link(**bad)) is None + + +def test_text_without_a_link_is_no_link(): + assert parse_link("Family@alice") is None + + +def test_a_code_is_folded_as_the_application_folds_it(): + assert normalize_code("k7p2-9wqx") == "K7P29WQX" + assert normalize_code(" ilou 0O1V ") == "110V001V" + + +def test_a_device_is_named_by_an_unambiguous_prefix(): + devices = [{"pk_ed25519": "ABCDEFGH1234"}, {"pk_ed25519": "ABCDEFGH5678"}] + assert find_device(devices, "ABCDEFGH5")["pk_ed25519"] == "ABCDEFGH5678" + with pytest.raises(CliError) as e: + find_device(devices, "ABCDEFGH") + assert e.value.code == USAGE + with pytest.raises(CliError) as e: + find_device(devices, "ABCD") + assert e.value.code == USAGE + with pytest.raises(CliError) as e: + find_device(devices, "ZZZZZZZZ") + assert e.value.code == NOT_FOUND + + +def test_a_bundle_sealed_here_opens_and_is_current_only_under_its_own_key(): + keys = {"skEd": base64.b64encode(b"ed").decode(), "skX": base64.b64encode(b"x").decode()} + m, other = b"\x01" * 32, b"\x02" * 32 + sealed = seal_bundle(keys, master=m, pepper_version=1, user_id="u", node_pk="pk") + assert base64.b64decode(sealed)[:5] == b"MBK3\x01" + assert open_bundle(sealed, master=m, legacy=None, user_id="u", node_pk="pk") == keys + assert bundle_is_current(sealed, master=m, user_id="u", node_pk="pk") + # Sealed under an earlier passphrase, or for another node: sealed again. + assert not bundle_is_current(sealed, master=other, user_id="u", node_pk="pk") + assert not bundle_is_current(sealed, master=m, user_id="u", node_pk="pk2") + legacy = base64.b64encode(b"MBK2" + b"\x00" * 40).decode() + assert not bundle_is_current(legacy, master=m, user_id="u", node_pk="pk") diff --git a/packages/meshbay-cli/tests/test_store_and_session.py b/packages/meshbay-cli/tests/test_store_and_session.py index 42458e2..07ba77e 100644 --- a/packages/meshbay-cli/tests/test_store_and_session.py +++ b/packages/meshbay-cli/tests/test_store_and_session.py @@ -244,7 +244,7 @@ async def test_a_machine_waiting_for_approval_retries_until_it_is_approved(monke monkeypatch.setattr(groups, "LINK_POLL", 0) tries, shown = [], [] - async def connect(account, group_id, node): + async def connect(account, group_id, node, join_code=None): tries.append(node["node_id"]) if len(tries) < 3: raise DeviceLinkPending("ABCD-EFGH", time.time() + 600) @@ -264,7 +264,7 @@ async def test_without_a_terminal_the_code_is_shown_and_the_run_ends(monkeypatch from meshbay_cli.client import groups from meshbay_cli.client.session import DeviceLinkPending - async def connect(account, group_id, node): + async def connect(account, group_id, node, join_code=None): raise DeviceLinkPending("ABCD-EFGH", time.time() + 600) monkeypatch.setattr(groups, "connect", connect) |