summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src')
-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
20 files changed, 989 insertions, 55 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