aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 00:56:04 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 00:56:04 +0200
commit6f68bb6c1970e26de1f66c073ea5d7fecd21840d (patch)
tree859c74db5819f1d49046e71a53e12afe5eb4193a /packages/meshbay-cli
parent16d92c96bde3a65fc54a93b4662df30cafc831e4 (diff)
downloadmeshbay-6f68bb6c1970e26de1f66c073ea5d7fecd21840d.tar.gz
feat(cli): invitations, join, decline, leave, devices, browser access
join accepts an invitation or redeems a link pasted as sent, then has the node admit a key pair made here, with the invitation's code (a link's code goes only to the node it names; one for a node that is away is kept for the next connection). decline, leave and devices revoke ask first, and refuse without --yes where there is no terminal. devices approve countersigns the one waiting request a code matches, none when several do. browser-access is the application's setting, kept per machine and never enabled from the hub's mirror: on leaves a copy sealed under M on each node, off withdraws it, unset touches nothing. A key pair made for a first contact is now kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/__main__.py26
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/account.py35
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/devices.py101
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/groups.py30
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/hub.py41
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/invite.py50
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/keys.py33
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/paths.py9
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/session.py133
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/browser_access.py72
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/context.py2
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/decline.py43
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/devices.py96
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/groups.py15
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/invitations.py66
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/join.py139
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/leave.py55
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/manpage.py74
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/output.py20
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/screens.py4
-rw-r--r--packages/meshbay-cli/tests/fake_hub.py70
-rw-r--r--packages/meshbay-cli/tests/fake_node.py86
-rw-r--r--packages/meshbay-cli/tests/test_cli_actions.py192
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py107
-rw-r--r--packages/meshbay-cli/tests/test_manpage.py2
-rw-r--r--packages/meshbay-cli/tests/test_membership.py78
-rw-r--r--packages/meshbay-cli/tests/test_store_and_session.py4
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)