summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/__main__.py16
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/account.py9
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/hub.py60
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/keys.py69
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/passphrase.py64
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/remove.py40
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/rewrap.py120
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/session.py144
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/account.py488
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/completion.py96
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py108
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/rm.py100
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/complete.py319
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/manpage.py30
-rw-r--r--packages/meshbay-cli/tests/fake_node.py29
-rw-r--r--packages/meshbay-cli/tests/test_account_flows.py285
-rw-r--r--packages/meshbay-cli/tests/test_account_keys.py135
-rw-r--r--packages/meshbay-cli/tests/test_cli_actions.py78
-rw-r--r--packages/meshbay-cli/tests/test_completion.py92
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py262
20 files changed, 2474 insertions, 70 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/__main__.py b/packages/meshbay-cli/src/meshbay_cli/__main__.py
index f5b002a..f5cee22 100644
--- a/packages/meshbay-cli/src/meshbay_cli/__main__.py
+++ b/packages/meshbay-cli/src/meshbay_cli/__main__.py
@@ -12,8 +12,10 @@ import sys
from . import __version__
from .commands import (
+ account,
browser_access,
chat,
+ completion,
decline,
devices,
get,
@@ -25,6 +27,7 @@ from .commands import (
login,
logout,
ls,
+ mkdir,
put,
rm,
search,
@@ -34,10 +37,12 @@ from .commands import (
from .commands.context import Context
from .errors import INTERRUPTED, USAGE, CliError
-ACTIONS = {"login": login, "logout": logout, "groups": groups, "invitations": invitations,
- "join": join, "decline": decline, "leave": leave, "ls": ls, "search": search, "get": get,
- "stream": stream, "put": put, "rm": rm, "chat": chat, "devices": devices,
- "trust": trust, "browser-access": browser_access, "help": help}
+ACTIONS = {"login": login, "logout": logout, "account": account, "groups": groups,
+ "invitations": invitations, "join": join, "decline": decline, "leave": leave,
+ "ls": ls, "search": search, "get": get,
+ "stream": stream, "put": put, "mkdir": mkdir, "rm": rm, "chat": chat, "devices": devices,
+ "trust": trust, "browser-access": browser_access,
+ "completion": completion, "help": help}
class _Parser(argparse.ArgumentParser):
@@ -116,6 +121,9 @@ def loop_factory():
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
+ if argv[:1] == ["__complete"]:
+ from .complete import main as complete
+ return complete(argv[1:])
if not argv and os.environ.get("MESHBAY_CLI_SELFTEST") == "1":
from .selftest import run
return run()
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/account.py b/packages/meshbay-cli/src/meshbay_cli/client/account.py
index 96483e9..fae1add 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/account.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/account.py
@@ -90,6 +90,13 @@ class Account:
return unb64(m) if m else None
@property
+ def previous_masters(self) -> list[bytes]:
+ """The `M` of passphrases this machine changed away from: a copy a node
+ still holds under one is carried over at the next connection."""
+ current = self.secrets.get("master")
+ return [unb64(m) for m in self.secrets.get("previous_masters", []) if m != current]
+
+ @property
def legacy(self) -> bytes | None:
a = self.secrets.get("legacy")
return unb64(a) if a else None
@@ -175,6 +182,8 @@ class Account:
"pepper_version": int(r.get("bundle_pepper_version") or 1),
"legacy": b64(a),
"identities": self.secrets.get("identities", {}) if same_account else {},
+ "previous_masters": [m for m in self.secrets.get("previous_masters", [])
+ if m != b64(master)] if same_account else [],
}
if remember:
device = (self.secrets.get("device_sk") if same_account else None)
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/hub.py b/packages/meshbay-cli/src/meshbay_cli/client/hub.py
index 94d22da..5b301f8 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/hub.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/hub.py
@@ -120,6 +120,66 @@ class Hub:
async def me(self, token: str) -> dict:
return await self._call("GET", "/v1/users/me", token=token)
+ # ── The account ──────────────────────────────────────────────────────────
+
+ async def captcha(self) -> dict | None:
+ """A proof-of-work challenge, or None when this hub has the check off."""
+ try:
+ return await self._call("GET", "/v1/users/captcha")
+ except HubError as e:
+ if e.status == 404:
+ return None
+ raise
+
+ async def register(self, username: str, email: str, auth_key: str,
+ recovery_key: str | None, captcha_token: str | None) -> dict:
+ body = {"username": username, "email": email, "auth_key": auth_key}
+ if recovery_key:
+ body["recovery_key"] = recovery_key
+ if captcha_token:
+ body["captcha_token"] = captcha_token
+ return await self._call("POST", "/v1/users/register", json=body)
+
+ async def verify_email(self, email: str, code: str) -> dict:
+ return await self._call("POST", "/v1/users/verify-email",
+ json={"email": email, "code": code})
+
+ async def bundle_pepper(self, token: str, auth_key: str) -> dict:
+ return await self._call("POST", "/v1/users/me/bundle-pepper", token=token,
+ json={"auth_key": auth_key})
+
+ async def change_passphrase(self, token: str, old_auth_key: str,
+ new_auth_key: str) -> dict:
+ return await self._call("POST", "/v1/users/password", token=token,
+ json={"old_auth_key": old_auth_key,
+ "new_auth_key": new_auth_key})
+
+ async def reset_request(self, username: str, email: str,
+ captcha_token: str | None) -> dict:
+ body = {"username": username, "email": email}
+ if captcha_token:
+ body["captcha_token"] = captcha_token
+ return await self._call("POST", "/v1/users/password/reset-request", json=body)
+
+ async def reset_passphrase(self, username: str, code: str, new_auth_key: str) -> dict:
+ return await self._call("POST", "/v1/users/password/reset", json={
+ "username": username, "code": code, "new_auth_key": new_auth_key})
+
+ async def change_email(self, token: str, email: str, auth_key: str) -> dict:
+ return await self._call("PATCH", "/v1/users/me", token=token,
+ json={"email": email, "auth_key": auth_key})
+
+ async def verify_email_change(self, token: str, code: str) -> dict:
+ return await self._call("POST", "/v1/users/verify-email-change", token=token,
+ json={"code": code})
+
+ async def revoke_sessions(self, token: str) -> dict:
+ return await self._call("POST", "/v1/users/me/sessions/revoke", token=token)
+
+ async def delete_account(self, token: str, auth_key: str) -> dict:
+ return await self._call("DELETE", "/v1/users/me", token=token,
+ json={"auth_key": auth_key})
+
# ── Groups and nodes ─────────────────────────────────────────────────────
async def my_groups(self, token: str) -> list[dict]:
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/keys.py b/packages/meshbay-cli/src/meshbay_cli/client/keys.py
index ecf44a5..2ac6f2c 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/keys.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/keys.py
@@ -135,6 +135,75 @@ def bundle_is_current(bundle_b64: str, *, master: bytes, user_id: str,
return False
+def opens_under(bundle_b64: str, master: bytes, *, user_id: str,
+ node_pk: str) -> dict | None:
+ """The `{skEd, skX}` of an `MBK3` bundle sealed under `master`, else None."""
+ try:
+ raw = unb64(bundle_b64)
+ if raw[:4] != BUNDLE_MAGIC:
+ return None
+ aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode()
+ return json.loads(AESGCM(node_bundle_key(master, node_pk)).decrypt(
+ raw[5:17], raw[17:], aad))
+ except Exception:
+ return None
+
+
+# ── The recovery key (docs/MESHBAY_DESIGN.md §3.6) ───────────────────────────
+#
+# 32 random bytes `R`, shown as 13 groups of four Base32 characters; the key
+# that seals a recovery copy is HKDF(R, "meshbay:recovery:v1:" + username),
+# used as it is — not per node — with the bundle's associated data and pepper
+# version 0. As `keyderive.js` `generateRecoveryKey`/`deriveRecoveryKey`.
+
+_B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
+
+
+def generate_recovery() -> tuple[bytes, str]:
+ """`R`, and the grouped mnemonic a person keeps."""
+ raw = os.urandom(32)
+ return raw, recovery_mnemonic(raw)
+
+
+def recovery_mnemonic(raw: bytes) -> str:
+ text = base64.b32encode(raw).decode().rstrip("=")
+ return " ".join(text[i:i + 4] for i in range(0, len(text), 4))
+
+
+def recovery_bytes(mnemonic: str) -> bytes:
+ """`R` from its mnemonic, however spaced or cased; ValueError if it is not one."""
+ clean = "".join(c for c in mnemonic.upper() if c.isalnum())
+ if any(c not in _B32 for c in clean):
+ raise ValueError("a recovery key is letters A to Z and digits 2 to 7")
+ raw = base64.b32decode(clean + "=" * (-len(clean) % 8)) if clean else b""
+ if len(raw) < 32:
+ raise ValueError("this recovery key is too short")
+ return raw[:32]
+
+
+def recovery_key(raw: bytes, username: str) -> bytes:
+ return hkdf(raw, f"meshbay:recovery:v1:{username}")
+
+
+def seal_recovery(keys: dict, *, rkey: bytes, user_id: str, node_pk: str) -> str:
+ nonce = os.urandom(12)
+ aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode()
+ plain = json.dumps({"skEd": keys["skEd"], "skX": keys["skX"]}).encode()
+ return b64(BUNDLE_MAGIC + bytes([0]) + nonce + AESGCM(rkey).encrypt(nonce, plain, aad))
+
+
+def open_recovery(bundle_b64: str, *, rkey: bytes, user_id: str, node_pk: str) -> dict:
+ try:
+ raw = unb64(bundle_b64)
+ if raw[:4] != BUNDLE_MAGIC:
+ raise ValueError
+ aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode()
+ return json.loads(AESGCM(rkey).decrypt(raw[5:17], raw[17:], aad))
+ except Exception as e:
+ raise BundleError("the node's recovery copy of your keys does not open with "
+ "this recovery key") from e
+
+
@dataclass
class Identity:
"""One account's keys on one node."""
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py b/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py
new file mode 100644
index 0000000..071c708
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py
@@ -0,0 +1,64 @@
+"""
+A new passphrase's floor, and the hub's proof of work — both checked here.
+
+The hub never sees a passphrase (docs/MESHBAY_DESIGN.md §3.1), so the floor —
+12 characters and about 60 estimated bits — can only be enforced by the client:
+`auth-page.js` `passwordBits`, the same estimate, so a passphrase the page
+refuses is refused here too.
+"""
+
+import hashlib
+import math
+import re
+
+MIN_LENGTH = 12
+MIN_BITS = 60
+_WEAK = re.compile(r"(password|motdepasse|azerty|qwerty|123456|meshbay)", re.I)
+
+
+def passphrase_bits(pw: str) -> int:
+ if not pw:
+ return 0
+ pool = 0
+ if re.search(r"[a-z]", pw):
+ pool += 26
+ if re.search(r"[A-Z]", pw):
+ pool += 26
+ if re.search(r"[0-9]", pw):
+ pool += 10
+ if re.search(r"[^A-Za-z0-9]", pw):
+ pool += 32
+ # JavaScript counts UTF-16 units; a character outside the BMP is two.
+ length = len(pw.encode("utf-16-le")) // 2
+ bits = length * math.log2(pool or 1)
+ if len(set(pw)) < length / 2:
+ bits *= 0.6
+ if re.fullmatch(r"[0-9]+", pw):
+ bits *= 0.5
+ if _WEAK.search(pw):
+ bits *= 0.3
+ # Math.round: halves go up.
+ return math.floor(bits + 0.5)
+
+
+def passphrase_problem(pw: str) -> str | None:
+ """Why a new passphrase is refused, or None."""
+ if len(pw.encode("utf-16-le")) // 2 < MIN_LENGTH:
+ return f"A passphrase has {MIN_LENGTH} characters at least."
+ if passphrase_bits(pw) < MIN_BITS:
+ return ("This passphrase is too easy to guess: make it longer, or mix in "
+ "other kinds of characters.")
+ return None
+
+
+def solve_captcha(challenge: dict) -> str | None:
+ """
+ The answer to `GET /v1/users/captcha`: the number below `max_number` whose
+ `<prefix><number>` hashes to `challenge` (`captcha.js` `solve`), as the token
+ the register and reset-request bodies carry. None if there is none.
+ """
+ prefix, want = challenge["prefix"], bytes.fromhex(challenge["challenge"])
+ for n in range(int(challenge["max_number"])):
+ if hashlib.sha256(f"{prefix}{n}".encode()).digest() == want:
+ return f"{prefix}{n}.{challenge['signature']}"
+ return None
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/remove.py b/packages/meshbay-cli/src/meshbay_cli/client/remove.py
index 370cf95..ed9c65a 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/remove.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/remove.py
@@ -1,15 +1,17 @@
"""
-Deleting a file from a group (docs/MESHBAY_DESIGN.md §5.4).
+Deleting a file, or an empty folder, from a group (docs/MESHBAY_DESIGN.md §5.4).
The node decides, and on two grounds only: a key paired with it as operator, or
a live device of the account that uploaded the file — any of them, so a file
sent from a phone is deleted from the laptop. Being the group's owner on the hub
-is neither. The request is answered with a challenge the client signs with its
-key on the node; the node rebuilds what was signed itself.
+is neither. A folder has no uploader: only the operator removes one, and only
+an empty one — the node never deletes recursively. The request is answered with
+a challenge the client signs with its key on the node; the node rebuilds what
+was signed itself.
"""
from meshbay_common import MNP_VERSION
-from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript
+from meshbay_common.adminop import OP_DIR_DELETE, OP_FILE_DELETE, admin_transcript
from ..errors import NODE, CliError
from .keys import b64, unb64
@@ -24,22 +26,32 @@ def may_delete(session: GroupSession, entry: dict, user_id: str) -> bool:
async def delete_file(session: GroupSession, file_id: str) -> None:
"""Delete one file. Raises `NodeRefusal` with the node's reason."""
- reply = await session.ch.request({"type": "file_delete", "v": MNP_VERSION,
- "file_id": file_id},
- "admin_challenge", "file_delete_ack")
- if reply["type"] == "file_delete_ack":
+ await _signed(session, {"type": "file_delete", "v": MNP_VERSION, "file_id": file_id},
+ OP_FILE_DELETE, file_id, "file_delete_ack")
+
+
+async def delete_dir(session: GroupSession, path: str) -> None:
+ """Remove one empty folder (root/folders), as the node's operator."""
+ await _signed(session, {"type": "dir_delete", "v": MNP_VERSION, "dir": path},
+ OP_DIR_DELETE, path, "dir_delete_ack")
+
+
+async def _signed(session: GroupSession, msg: dict, op: str, subject: str,
+ ack: str) -> None:
+ reply = await session.ch.request(msg, "admin_challenge", ack)
+ if reply["type"] == ack:
return
# What is signed is checked against what was asked, never taken from the
- # node: a challenge for another operation, file, group or node is refused.
- if (reply.get("op") != OP_FILE_DELETE or reply.get("subject") != file_id
+ # node: a challenge for another operation, subject, group or node is refused.
+ if (reply.get("op") != op or reply.get("subject") != subject
or reply.get("group_id") != session.group_id
or reply.get("node_pk") != session.node_pk):
raise CliError(NODE, "The node asked to sign something other than this deletion; "
"nothing was signed.")
- transcript = admin_transcript(op=OP_FILE_DELETE, node_pk_b64=session.node_pk,
- group_id=session.group_id, subject=file_id,
+ transcript = admin_transcript(op=op, node_pk_b64=session.node_pk,
+ group_id=session.group_id, subject=subject,
nonce=unb64(reply["nonce"]), ts=int(reply["ts"]))
await session.ch.request({"type": "admin_response", "v": MNP_VERSION,
- "op_id": reply["op_id"], "op": OP_FILE_DELETE,
+ "op_id": reply["op_id"], "op": op,
"signature": b64(session.identity.sk_ed.sign(transcript))},
- "file_delete_ack")
+ ack)
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/rewrap.py b/packages/meshbay-cli/src/meshbay_cli/client/rewrap.py
new file mode 100644
index 0000000..3ad74bb
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/rewrap.py
@@ -0,0 +1,120 @@
+"""
+Carrying the account's keys on every node across a new passphrase
+(docs/MESHBAY_DESIGN.md §3.6).
+
+A node keeps, for browsers, a copy of the account's keys there sealed under
+`M` — which the passphrase derives — and perhaps a second copy under the
+recovery key. A new passphrase means a new `M`: each copy is opened (under the
+old `M`, else the recovery copy under the recovery key) and sealed again, the
+same keys, under the new one. Never this machine's keys in place of the
+browsers': a copy is carried, not replaced — except after a reset, for an
+account that leaves its keys for browsers from here, where the old copy no
+longer opens and this machine's is the one it chose to leave.
+
+Once per node, whatever number of groups it serves: the copy is per account
+and per node. A node that holds no keys of this account has nothing to carry;
+a node out of reach keeps its copy as it is, and this machine carries it at
+its next connection there (`session._settle_bundle`) when the old `M` is kept.
+"""
+
+import logging
+from collections.abc import Callable
+from dataclasses import dataclass, field
+
+from meshbay_common import MNP_VERSION
+
+from ..errors import NODE, CliError
+from .account import Account
+from .keys import BundleError, seal_bundle, seal_recovery
+from .paths import handle
+from .session import NodeKeyChanged, NothingHere, Rewrap, connect, open_held
+
+log = logging.getLogger(__name__)
+
+
+@dataclass
+class Carried:
+ done: list[str] = field(default_factory=list) # node keys sealed again
+ nothing: list[str] = field(default_factory=list) # groups with nothing to carry
+ unreachable: list[str] = field(default_factory=list) # groups: no node online
+ failed: list[str] = field(default_factory=list) # "group: why"
+
+ @property
+ def stragglers(self) -> list[str]:
+ return self.unreachable + self.failed
+
+
+async def carry(account: Account, groups: list[dict], *, master: bytes, pepper_version: int,
+ recovery: bytes | None = None, own_keys: bool = False,
+ only: set[str] | None = None,
+ progress: Callable[[str], None] | None = None) -> Carried:
+ """
+ Seal every reachable node's copy under `master`. `account` opens them: its
+ `M` is the one they are sealed under now. `recovery`: the recovery key, which
+ opens the recovery copy and gets a fresh one. `own_keys`: where no copy opens,
+ leave this machine's keys instead. `only`: these node keys and no others.
+ """
+ out = Carried()
+ seen: set[str] = set()
+ for group in groups:
+ name = handle(group)
+ try:
+ nodes = await account.call(account.hub.group_nodes, group["id"])
+ except CliError as e:
+ out.failed.append(f"{name}: {e.message}")
+ continue
+ nodes = [n for n in nodes if only is None or n["pk_node"] in only]
+ fresh = [n for n in nodes if n["pk_node"] not in seen]
+ if not nodes:
+ if only is None:
+ out.unreachable.append(name)
+ continue
+ if not fresh:
+ continue
+ for node in fresh:
+ if progress:
+ progress(name)
+ try:
+ what = await _carry_one(account, group, node, master, pepper_version,
+ recovery, own_keys)
+ except NodeKeyChanged as e:
+ out.failed.append(f"{name}: {e.message.splitlines()[0]}")
+ continue
+ except CliError as e:
+ out.failed.append(f"{name}: {e.message}")
+ continue
+ seen.add(node["pk_node"])
+ if what == "done":
+ out.done.append(node["pk_node"])
+ else:
+ out.nothing.append(name)
+ return out
+
+
+async def _carry_one(account: Account, group: dict, node: dict, master: bytes,
+ pepper_version: int, recovery: bytes | None, own_keys: bool) -> str:
+ try:
+ session = await connect(account, group["id"], node, rewrap=Rewrap(recovery))
+ except NothingHere:
+ return "nothing"
+ try:
+ held, node_pk = session.held, session.node_pk
+ if not held:
+ return "nothing" # this machine keeps its keys; none on the node
+ try:
+ keys = open_held(account, held, node_pk, recovery)
+ except BundleError as e:
+ if not own_keys:
+ raise CliError(NODE, f"{e}; left as it is") from e
+ keys = session.identity.to_pkcs8()
+ msg = {"type": "keypair_bundle_store", "v": MNP_VERSION,
+ "bundle_enc": seal_bundle(keys, master=master, pepper_version=pepper_version,
+ user_id=account.user_id, node_pk=node_pk)}
+ if recovery is not None:
+ msg["bundle_enc_recovery"] = seal_recovery(keys, rkey=recovery,
+ user_id=account.user_id,
+ node_pk=node_pk)
+ await session.ch.request(msg, "ack")
+ return "done"
+ finally:
+ await session.close()
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/session.py b/packages/meshbay-cli/src/meshbay_cli/client/session.py
index 41bf0d6..d10acc8 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/session.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/session.py
@@ -31,6 +31,7 @@ import logging
import os
import struct
import time
+from dataclasses import dataclass
from datetime import UTC, datetime
import msgpack
@@ -64,11 +65,14 @@ from meshbay_common.join import join_transcript
from ..errors import NODE, NODE_KEY, VERSION, CliError
from .account import Account
from .keys import (
+ LEGACY_MAGIC,
BundleError,
Identity,
b64,
bundle_is_current,
open_bundle,
+ open_recovery,
+ opens_under,
seal_bundle,
unb64,
)
@@ -94,6 +98,25 @@ class NodeRefusal(CliError):
self.refusal_code = code
+class NothingHere(CliError):
+ """A node that holds no keys of this account: nothing to carry across."""
+
+ def __init__(self):
+ super().__init__(NODE, "this account has no keys on this node")
+
+
+@dataclass
+class Rewrap:
+ """
+ A connection made only to carry this account's copy on the node across a
+ passphrase change or a reset: it never makes new keys, never asks for a
+ device approval, and reads the recovery copy with `recovery` (the recovery
+ key) where the passphrase copy does not open.
+ """
+
+ recovery: bytes | None = None
+
+
class NodeKeyChanged(CliError):
def __init__(self, node_id: str, pinned: str, now: str):
super().__init__(
@@ -214,6 +237,9 @@ class GroupSession:
# paired with the node — the authority to delete anyone's file. Not the
# hub's word on who owns the group (docs/MESHBAY_DESIGN.md §3.3).
self.role = role
+ # What the node keeps of this account's keys, fetched by a Rewrap
+ # connection: `bundle_enc` and `bundle_enc_recovery`, or empty.
+ self.held: dict = {}
async def index(self) -> dict:
"""`{version, entries, dirs, roots}`, opened under the group key."""
@@ -230,7 +256,8 @@ class GroupSession:
async def connect(account: Account, group_id: str, node: dict,
- join_code: str | None = None) -> GroupSession:
+ join_code: str | None = None, *,
+ rewrap: Rewrap | 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
@@ -262,7 +289,7 @@ async def connect(account: Account, group_id: str, node: dict,
"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, join_code)
+ mnp_token, binding, join_code, rewrap)
except BaseException:
with contextlib.suppress(Exception):
await pc.close()
@@ -271,7 +298,7 @@ async def connect(account: Account, group_id: str, node: dict,
async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: str,
listed_pk: str, mnp_token: str, binding: bytes,
- join_code: str | None) -> GroupSession:
+ join_code: str | None, rewrap: Rewrap | None = None) -> GroupSession:
nonce_c = os.urandom(32)
challenge = await ch.request(
{"type": "handshake", "v": MNP_VERSION, "v_min": MNP_MIN_SUPPORTED,
@@ -293,8 +320,14 @@ 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, bundle = await _identity(account, ch, node_pk)
- if bundle is None and account.browser_access:
+ held: dict = {}
+ if rewrap is not None:
+ held = await _fetch_held(ch)
+ identity, source = await _rewrap_identity(account, node_pk, held, rewrap)
+ bundle = held.get("bundle_enc") or ""
+ else:
+ identity, source, bundle = await _identity(account, ch, node_pk)
+ if bundle is None and (account.browser_access or account.previous_masters):
# 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.
@@ -314,7 +347,7 @@ async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id:
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":
+ if reason == "unknown_device" and source != "bundle" and rewrap is None:
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)
@@ -354,16 +387,59 @@ 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)
- await _settle_bundle(account, ch, identity, node_pk, bundle)
- return GroupSession(pc, ch, group_id, node_id, node_pk, gek, config,
- identity, nonce_s, str(joined.get("role") or ""))
+ if rewrap is None:
+ await _settle_bundle(account, ch, identity, node_pk, bundle)
+ session = GroupSession(pc, ch, group_id, node_id, node_pk, gek, config,
+ identity, nonce_s, str(joined.get("role") or ""))
+ session.held = held
+ return session
-async def _fetch_bundle(ch: Channel) -> str:
- """The node's copy of this account's keys, or "" when it keeps none."""
+async def _fetch_held(ch: Channel) -> dict:
+ """The node's copies of this account's keys: `bundle_enc`, sealed under `M`,
+ and `bundle_enc_recovery`, under the recovery key — {} 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 ""
+ if not found.get("found"):
+ return {}
+ return {k: str(found[k]) for k in ("bundle_enc", "bundle_enc_recovery") if found.get(k)}
+
+
+async def _fetch_bundle(ch: Channel) -> str:
+ """The node's copy of this account's keys, or "" when it keeps none."""
+ return (await _fetch_held(ch)).get("bundle_enc", "")
+
+
+def open_held(account: Account, held: dict, node_pk: str,
+ recovery: bytes | None) -> dict:
+ """The keys in the node's copy: under this session's `M` (or the legacy key),
+ else under the recovery key. BundleError when none opens it."""
+ try:
+ return open_bundle(held.get("bundle_enc", ""), master=account.master,
+ legacy=account.legacy, user_id=account.user_id,
+ node_pk=node_pk)
+ except BundleError:
+ if recovery is None or not held.get("bundle_enc_recovery"):
+ raise
+ return open_recovery(held["bundle_enc_recovery"], rkey=recovery,
+ user_id=account.user_id, node_pk=node_pk)
+
+
+async def _rewrap_identity(account: Account, node_pk: str, held: dict,
+ rewrap: Rewrap) -> tuple[Identity, str]:
+ """The keys to join with on a Rewrap connection: kept here, or the node's
+ copy opened — never new ones, and never a device request."""
+ kept = account.identity(node_pk)
+ if kept is not None:
+ return kept, "kept"
+ if not held.get("bundle_enc") and not held.get("bundle_enc_recovery"):
+ raise NothingHere()
+ try:
+ identity = Identity.from_pkcs8(open_held(account, held, node_pk, rewrap.recovery))
+ except BundleError as e:
+ raise CliError(NODE, str(e)) from e
+ await account.keep_identity(node_pk, identity)
+ return identity, "bundle"
async def _identity(account: Account, ch: Channel,
@@ -397,31 +473,51 @@ async def _identity(account: Account, ch: Channel,
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.
+ What to leave on this node of the account's keys.
+
+ A copy sealed under a passphrase this machine changed away from is sealed
+ again, same keys, under the current `M` — the node was out of reach when the
+ passphrase changed (docs/MESHBAY_DESIGN.md §3.6). Then what `browser-access`
+ says (spec §2.12): `on`, a copy of this machine's keys where the node holds
+ none, or only the older `MBK2`; `off`, the node's copy withdrawn; unset,
+ nothing. A copy in `MBK3` that opens under no key this machine knows is left
+ as it is: sealed under a passphrase set since, elsewhere, it is the one the
+ browsers open — rewriting it under the old one would lock them out. A
+ failure costs the browsers their copy, never this session.
"""
setting = account.browser_access
- if not setting or bundle is None:
+ if bundle is None or not account.master:
return
try:
+ if bundle and setting != "off":
+ earlier = next((keys for m in account.previous_masters
+ if (keys := opens_under(bundle, m, user_id=account.user_id,
+ node_pk=node_pk)) is not None), None)
+ if earlier is not None:
+ await _store(account, ch, earlier, node_pk)
+ return
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(
+ elif setting == "on" and (not bundle or unb64(bundle)[:4] == LEGACY_MAGIC):
+ await _store(account, ch, identity.to_pkcs8(), node_pk)
+ elif setting == "on" 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")
+ log.warning("The node's copy of your keys is sealed under a passphrase this "
+ "machine does not know — changed elsewhere? It is left as it is; "
+ "run `meshbay-cli login` to sign in with the current one.")
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)
+async def _store(account: Account, ch: Channel, keys: dict, node_pk: str) -> None:
+ sealed = seal_bundle(keys, 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")
+
+
# What a refused join means, for the person reading it.
JOIN_REFUSALS = {
"code_required": "this node has not admitted you yet; it needs the code of your "
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/account.py b/packages/meshbay-cli/src/meshbay_cli/commands/account.py
new file mode 100644
index 0000000..2137995
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/account.py
@@ -0,0 +1,488 @@
+"""
+`account`: the account itself — see it, create one, verify its address, change
+or reset the passphrase, change the address, end every session, erase it.
+
+What the application's sign-up form and Profile page do, with the same rules
+(docs/MESHBAY_DESIGN.md §3.1, §3.6). The passphrase never leaves this machine:
+the hub is shown the key derived from it, and the floor a new one must clear
+is checked here, as the page checks it. A new passphrase carries the account's
+keys on every node across first, and only then is the hub told — so a change
+that cannot be carried leaves the account as it was.
+"""
+
+import asyncio
+import getpass
+import json
+import os
+import sys
+import time
+from pathlib import Path
+
+from ..client.account import Account, NoPassphrase
+from ..client.groups import my_groups
+from ..client.hub import HubError
+from ..client.keys import (
+ auth_key,
+ b64,
+ bundle_master_key,
+ generate_recovery,
+ passphrase_half,
+ recovery_bytes,
+ recovery_key,
+ unb64,
+)
+from ..client.passphrase import passphrase_problem, solve_captcha
+from ..client.rewrap import Carried, carry
+from ..client.store import FILE_KEY_NOTICE, KEY_FILE
+from ..errors import AUTH, NOT_FOUND, REFUSED, USAGE, CliError
+from ..output import confirm, err, safe, sentence
+from .context import Context
+
+HELP = ("your account: show it, register, verify, change or reset the passphrase, "
+ "change the e-mail address, sign out everywhere, delete it")
+VERBS = ("show", "register", "verify", "passphrase", "reset", "email",
+ "sign-out-everywhere", "delete")
+USERNAME_MIN = 8 # the hub's USERNAME_MIN_LEN
+# How many earlier `M` this machine keeps, to carry copies on nodes that were
+# out of reach when the passphrase changed.
+KEEP_MASTERS = 5
+
+
+def add_parser(sub, parent) -> None:
+ p = sub.add_parser("account", parents=[parent], help=HELP, description=sentence(HELP)
+ + ". With no verb, shows the account.")
+ p.add_argument("verb", nargs="?", default="show", choices=VERBS,
+ help="what to do; show when left out")
+ p.add_argument("arg", nargs="?", metavar="CODE|ADDRESS",
+ help="verify: the code e-mailed at registration; email: the new "
+ "address")
+ p.add_argument("--email", metavar="ADDRESS",
+ help="register, verify, reset: the account's e-mail address")
+ p.add_argument("--code", help="reset: the code e-mailed for it; email: the code sent "
+ "to the new address")
+ p.add_argument("--new-password-file", metavar="FILE",
+ help="passphrase: read the new passphrase from FILE's first line "
+ "(- for stdin); also MESHBAY_NEW_PASSWORD")
+ p.add_argument("--recovery-key-file", metavar="FILE",
+ help="reset: read the recovery key from FILE, to restore your keys "
+ "on the nodes")
+ p.add_argument("--email-recovery-key", action="store_true",
+ help="register: also send the recovery key with the verification "
+ "e-mail")
+ p.add_argument("--no-remember", action="store_true",
+ help="reset: sign in afterwards without storing the session")
+ p.add_argument("-y", "--yes", action="store_true",
+ help="delete without asking for confirmation")
+
+
+async def run(ctx: Context) -> int:
+ a = ctx.args
+ await ctx.hub.check_version()
+ verb = a.verb
+ if verb == "register":
+ return await _register(ctx)
+ if verb == "verify":
+ return await _verify(ctx)
+ if verb == "reset":
+ return await _reset(ctx)
+ account = ctx.account()
+ try:
+ if verb == "show":
+ return await _show(ctx, account)
+ if verb == "passphrase":
+ return await _change_passphrase(ctx, account)
+ if verb == "email":
+ return await _email(ctx, account)
+ if verb == "sign-out-everywhere":
+ return await _sign_out_everywhere(ctx, account)
+ return await _delete(ctx, account)
+ finally:
+ await account.close()
+
+
+# ── Reading what a person types ──────────────────────────────────────────────
+
+def _ask(prompt: str, given: str | None, what: str) -> str:
+ if given:
+ return given.strip()
+ if not sys.stdin.isatty():
+ raise CliError(USAGE, f"Name the {what}: there is no terminal to ask on.")
+ value = input(prompt).strip()
+ if not value:
+ raise CliError(USAGE, f"No {what} given.")
+ return value
+
+
+def _read_first_line(path: str) -> str:
+ try:
+ text = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8")
+ except OSError as e:
+ raise CliError(USAGE, f"Cannot read {path}: {e.strerror}") from e
+ return text.splitlines()[0] if text else ""
+
+
+def _new_passphrase(given: str | None) -> str:
+ """A new passphrase: `given`, else typed twice at a terminal; held to the floor."""
+ if given is None:
+ if not sys.stdin.isatty():
+ raise NoPassphrase("A new passphrase is needed and there is no terminal to "
+ "ask on.")
+ given = getpass.getpass("New passphrase: ")
+ if problem := passphrase_problem(given):
+ raise CliError(USAGE, problem)
+ if getpass.getpass("The same again: ") != given:
+ raise CliError(USAGE, "The two passphrases differ; nothing was changed.")
+ if problem := passphrase_problem(given):
+ raise CliError(USAGE, problem)
+ return given
+
+
+def _given_passphrase(ctx: Context) -> str | None:
+ """The global sources of a passphrase, without prompting."""
+ a = ctx.args
+ if a.password is not None:
+ return a.password
+ if a.password_file:
+ return _read_first_line(a.password_file)
+ return os.environ.get("MESHBAY_PASSWORD")
+
+
+async def _captcha(ctx: Context) -> str | None:
+ challenge = await ctx.hub.captcha()
+ if challenge is None:
+ return None
+ token = await asyncio.to_thread(solve_captcha, challenge)
+ if token is None:
+ raise CliError(REFUSED, "The hub's proof of work has no answer; try again.")
+ return token
+
+
+def _hub_refusal(e: HubError, what: str) -> CliError:
+ if e.status == 429:
+ return CliError(REFUSED, f"{what}: too many attempts — {e.detail}")
+ if e.status in (401, 403):
+ return CliError(AUTH, f"{what}: {e.detail}")
+ if e.status == 404:
+ return CliError(NOT_FOUND, f"{what}: {e.detail}")
+ return CliError(REFUSED, f"{what}: {e.detail}")
+
+
+def _say(ctx: Context, data: dict, text: str) -> None:
+ if ctx.args.json:
+ print(json.dumps(data, ensure_ascii=False))
+ elif not ctx.args.quiet:
+ print(text)
+
+
+# ── show ─────────────────────────────────────────────────────────────────────
+
+async def _show(ctx: Context, account: Account) -> int:
+ me = await account.call(account.hub.me)
+ if ctx.args.json:
+ print(json.dumps({"hub": ctx.hub.url, **{k: me.get(k) for k in (
+ "user_id", "username", "email", "role", "status")}}, ensure_ascii=False))
+ return 0
+ print(f"Account: {safe(me.get('username', ''))}\n"
+ f"Hub: {ctx.hub.url}\n"
+ f"E-mail: {safe(me.get('email', ''))}\n"
+ f"Status: {safe(me.get('status', ''))}")
+ if me.get("role") and me["role"] != "user":
+ print(f"Role: {safe(me['role'])}")
+ return 0
+
+
+# ── register, verify ─────────────────────────────────────────────────────────
+
+async def _register(ctx: Context) -> int:
+ a = ctx.args
+ username = _ask("Username: ", ctx.username, "account name (--user)")
+ if len(username) < USERNAME_MIN or len(username) > 64 or \
+ not username.replace("_", "").replace("-", "").replace(".", "").isalnum():
+ raise CliError(USAGE, f"A username is {USERNAME_MIN} to 64 letters, digits, "
+ "-, _ or .")
+ email = _ask("E-mail address: ", a.email, "e-mail address (--email)")
+ passphrase = _new_passphrase(_given_passphrase(ctx))
+ ak = await asyncio.to_thread(auth_key, username, passphrase)
+ raw, mnemonic = generate_recovery()
+ try:
+ try:
+ reg = await ctx.hub.register(username, email, ak,
+ mnemonic if a.email_recovery_key else None,
+ await _captcha(ctx))
+ except HubError as e:
+ if e.detail not in ("captcha_failed", "captcha_required"):
+ raise
+ # An answer is spent once checked; a fresh one, once.
+ reg = await ctx.hub.register(username, email, ak,
+ mnemonic if a.email_recovery_key else None,
+ await _captcha(ctx))
+ except HubError as e:
+ raise _hub_refusal(e, "Not registered") from e
+
+ if ctx.args.json:
+ print(json.dumps({"user_id": reg.get("user_id"), "username": username,
+ "recovery_key": mnemonic,
+ "recovery_key_emailed": bool(a.email_recovery_key),
+ "email_verification_required": True}))
+ else:
+ print(f"Account {safe(username)} created on {ctx.hub.url}.\n\n"
+ "Your recovery key — shown this once, kept nowhere by MeshBay"
+ + (" (it is also in the verification e-mail)" if a.email_recovery_key
+ else "") + ":\n\n"
+ f" {mnemonic}\n\n"
+ "Keep it somewhere safe, apart from your passphrase. If you forget the "
+ "passphrase, it is what gives you your groups back.")
+ del raw
+ if not sys.stdin.isatty() or ctx.args.json:
+ err(f"A code was sent to {safe(email)}. Then: meshbay-cli account verify CODE "
+ f"--email {safe(email)}")
+ return 0
+ code = input(f"\nThe code sent to {email} (Enter to do it later): ").strip()
+ if not code:
+ err(f"Later: meshbay-cli account verify CODE --email {safe(email)}")
+ return 0
+ await _verify_code(ctx, email, code)
+ return 0
+
+
+async def _verify(ctx: Context) -> int:
+ a = ctx.args
+ code = _ask("The code: ", a.arg or a.code, "code")
+ email = _ask("E-mail address: ", a.email, "e-mail address (--email)")
+ await _verify_code(ctx, email, code)
+ return 0
+
+
+async def _verify_code(ctx: Context, email: str, code: str) -> None:
+ try:
+ await ctx.hub.verify_email(email, code)
+ except HubError as e:
+ raise _hub_refusal(e, "Not verified") from e
+ _say(ctx, {"verified": True, "email": email},
+ "Verified. Sign in with: meshbay-cli login")
+
+
+# ── passphrase: change a known one ───────────────────────────────────────────
+
+async def _change_passphrase(ctx: Context, account: Account) -> int:
+ a = ctx.args
+ old = ctx.passphrase()
+ given = (_read_first_line(a.new_password_file) if a.new_password_file
+ else os.environ.get("MESHBAY_NEW_PASSWORD"))
+ new = _new_passphrase(given)
+ if new == old:
+ raise CliError(USAGE, "The new passphrase is the current one; nothing was changed.")
+ me = await account.call(account.hub.me)
+ if int(me.get("passphrase_locked_for") or 0) > 0:
+ raise CliError(AUTH, "Sign-in is locked for this account for "
+ f"{max(1, int(me['passphrase_locked_for']) // 60)} min; "
+ "nothing was changed.")
+ user, uid = account.username, me["user_id"]
+ old_ak = await asyncio.to_thread(auth_key, user, old)
+ try:
+ pep = await account.call(account.hub.bundle_pepper, old_ak)
+ except HubError as e:
+ if e.status == 403:
+ raise CliError(AUTH, "The current passphrase does not match; nothing was "
+ "changed.") from e
+ raise _hub_refusal(e, "Not changed") from e
+ pepper, version = unb64(pep["bundle_pepper"]), int(pep.get("bundle_pepper_version") or 1)
+ old_a = await asyncio.to_thread(passphrase_half, user, old)
+ new_a = await asyncio.to_thread(passphrase_half, user, new)
+ old_m, new_m = (bundle_master_key(old_a, pepper, uid),
+ bundle_master_key(new_a, pepper, uid))
+ # What opens the copies as they are: the current passphrase's keys.
+ await account.update(lambda st, se: se.update(
+ master=b64(old_m), legacy=b64(old_a), pepper_version=version))
+
+ groups = await my_groups(account)
+ progress = None if a.quiet or a.json else (lambda g: err(f" {safe(g)}..."))
+ if progress:
+ err("Sealing your keys on each node under the new passphrase:")
+ carried = await carry(account, groups, master=new_m, pepper_version=version,
+ progress=progress)
+ new_ak = await asyncio.to_thread(auth_key, user, new)
+ try:
+ r = await account.call(account.hub.change_passphrase, old_ak, new_ak)
+ except (HubError, CliError) as e:
+ # The hub kept the old passphrase: the copies go back under it, opened
+ # with the new one they are sealed under now.
+ await account.update(lambda st, se: se.update(master=b64(new_m), legacy=b64(new_a)))
+ try:
+ back = await carry(account, groups, master=old_m, pepper_version=version,
+ only=set(carried.done))
+ finally:
+ await account.update(lambda st, se: se.update(master=b64(old_m),
+ legacy=b64(old_a)))
+ detail = getattr(e, "detail", None) or getattr(e, "message", str(e))
+ lost = len(carried.done) - len(back.done)
+ raise CliError(REFUSED, f"The hub refused the new passphrase ({detail}); "
+ "nothing was changed" + (
+ f", but {lost} node(s) could not be set back and "
+ "hold your keys under the new one" if lost else "")
+ + ".") from e
+
+ def commit(state: dict, secrets: dict) -> None:
+ kept = [m for m in secrets.get("previous_masters", []) if m != b64(old_m)]
+ secrets.update(master=b64(new_m), legacy=b64(new_a), pepper_version=version,
+ previous_masters=([b64(old_m)] + kept)[:KEEP_MASTERS],
+ access_token=r["access_token"], refresh_token=r["refresh_token"])
+ secrets["access_exp"] = time.time() + int(r.get("expires_in", 0))
+ await account.update(commit)
+ _report(ctx, carried, "Passphrase changed", changed=True)
+ return 0
+
+
+def _report(ctx: Context, carried: Carried, head: str, *, changed: bool) -> None:
+ if ctx.args.json:
+ print(json.dumps({"changed": changed, "nodes_sealed": len(carried.done),
+ "unreachable": carried.unreachable, "failed": carried.failed}))
+ return
+ print(f"{head}. Your keys were sealed again on {len(carried.done)} node"
+ f"{'s' if len(carried.done) != 1 else ''}.")
+ if carried.stragglers:
+ err("Not reached — " + ("this machine carries them over the next time it "
+ "connects there:" if changed else
+ "these groups may need their operator (`member unpin`, "
+ "then a fresh code):"))
+ for s in carried.stragglers:
+ err(f" {safe(s)}")
+
+
+# ── reset: a forgotten passphrase ────────────────────────────────────────────
+
+async def _reset(ctx: Context) -> int:
+ a = ctx.args
+ username = _ask("Username: ", ctx.username or ctx.stored_username,
+ "account name (--user)")
+ code = a.code
+ if not code:
+ email = _ask("The account's e-mail address: ", a.email, "e-mail address (--email)")
+ try:
+ await ctx.hub.reset_request(username, email, await _captcha(ctx))
+ except HubError as e:
+ raise _hub_refusal(e, "No code sent") from e
+ err(f"If {safe(username)} and {safe(email)} are an account's, a code is on its "
+ "way there (valid one hour).")
+ if not sys.stdin.isatty():
+ err(f"Then: meshbay-cli account reset -u {safe(username)} --code CODE")
+ return 0
+ code = _ask("The code: ", None, "code")
+ passphrase = _new_passphrase(_given_passphrase(ctx))
+ recovery = None
+ if a.recovery_key_file:
+ text = (sys.stdin.read() if a.recovery_key_file == "-"
+ else Path(a.recovery_key_file).read_text(encoding="utf-8"))
+ recovery = text.strip()
+ elif sys.stdin.isatty():
+ recovery = input("Your recovery key (Enter if you have none): ").strip()
+ rkey = None
+ if recovery:
+ try:
+ rkey = recovery_key(recovery_bytes(recovery), username)
+ except ValueError as e:
+ raise CliError(USAGE, f"{sentence(str(e))}; nothing was changed.") from e
+
+ new_ak = await asyncio.to_thread(auth_key, username, passphrase)
+ try:
+ await ctx.hub.reset_passphrase(username, code.strip(), new_ak)
+ except HubError as e:
+ raise _hub_refusal(e, "Not reset") from e
+
+ # Signed in afresh, as `login` does: the reset ended every session and
+ # dropped every device key on the hub.
+ remember = not a.no_remember
+ account = Account(ctx.hub, username, lambda: passphrase)
+ stored = ctx.store.peek()
+ if stored.get("hub") == ctx.hub.url and stored.get("username") == username:
+ try:
+ ctx.store.load()
+ account.state, account.secrets = ctx.store.state, ctx.store.secrets
+ except CliError:
+ pass
+ try:
+ await account.sign_in(remember=remember)
+ if remember:
+ if await account.remember(ctx.store) == KEY_FILE:
+ err(FILE_KEY_NOTICE)
+ progress = None if a.quiet or a.json else (lambda g: err(f" {safe(g)}..."))
+ if progress:
+ err("Restoring your keys on each node:")
+ carried = await carry(account, await my_groups(account), master=account.master,
+ pepper_version=account.pepper_version, recovery=rkey,
+ own_keys=account.browser_access == "on", progress=progress)
+ finally:
+ await account.close()
+ _report(ctx, carried, "Passphrase reset, and signed in", changed=False)
+ if not rkey and not ctx.args.json:
+ err("Without the recovery key, groups whose keys this machine does not keep "
+ "need their operator (`member unpin`, then a fresh code).")
+ return 0
+
+
+# ── email, sign-out-everywhere, delete ───────────────────────────────────────
+
+async def _email(ctx: Context, account: Account) -> int:
+ a = ctx.args
+ if a.code:
+ try:
+ r = await account.call(account.hub.verify_email_change, a.code.strip())
+ except HubError as e:
+ raise _hub_refusal(e, "Not changed") from e
+ _say(ctx, {"email": r.get("email")}, f"E-mail address changed to "
+ f"{safe(r.get('email', ''))}.")
+ return 0
+ address = _ask("New e-mail address: ", a.arg, "new address")
+ ak = await asyncio.to_thread(auth_key, account.username, ctx.passphrase())
+ try:
+ await account.call(account.hub.change_email, address, ak)
+ except HubError as e:
+ raise _hub_refusal(e, "Not changed") from e
+ if ctx.args.json or not sys.stdin.isatty():
+ _say(ctx, {"email_verification_required": True, "email": address},
+ f"A code was sent to {safe(address)}. Then: meshbay-cli account email "
+ "--code CODE")
+ return 0
+ code = input(f"The code sent to {address}: ").strip()
+ if not code:
+ err("Later: meshbay-cli account email --code CODE")
+ return 0
+ try:
+ r = await account.call(account.hub.verify_email_change, code)
+ except HubError as e:
+ raise _hub_refusal(e, "Not changed") from e
+ print(f"E-mail address changed to {safe(r.get('email', ''))}.")
+ return 0
+
+
+async def _sign_out_everywhere(ctx: Context, account: Account) -> int:
+ r = await account.call(account.hub.revoke_sessions)
+ n = int(r.get("revoked") or 0)
+ _say(ctx, {"revoked": n},
+ f"Signed out everywhere: {n} session{'s' if n != 1 else ''} ended. Devices "
+ "with a key of their own (this machine, if it is remembered) sign back in with "
+ "it; remove one from your devices to stop it.")
+ return 0
+
+
+async def _delete(ctx: Context, account: Account) -> int:
+ a = ctx.args
+ confirm(f"Erase the account {account.username} on {ctx.hub.url}, and every group "
+ "it owns? This cannot be undone.", a.yes)
+ ak = await asyncio.to_thread(auth_key, account.username, ctx.passphrase())
+ try:
+ r = await account.call(account.hub.delete_account, ak)
+ except HubError as e:
+ if e.status == 403:
+ raise CliError(AUTH, "The passphrase does not match; nothing was erased.") from e
+ raise _hub_refusal(e, "Not erased") from e
+ if account.remembered:
+ with ctx.store.locked():
+ ctx.store.erase()
+ gone = r.get("groups_deleted") or []
+ _say(ctx, {"deleted": True, "groups_deleted": gone},
+ f"Account {safe(account.username)} erased"
+ + (f", with {len(gone)} group{'s' if len(gone) != 1 else ''} it owned"
+ if gone else "") + ".")
+ return 0
+
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/completion.py b/packages/meshbay-cli/src/meshbay_cli/commands/completion.py
new file mode 100644
index 0000000..831c012
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/completion.py
@@ -0,0 +1,96 @@
+"""
+`completion`: the script that makes Tab complete meshbay-cli in a shell.
+
+bash: eval "$(meshbay-cli completion bash)" (in ~/.bashrc)
+PowerShell: meshbay-cli completion powershell | Out-String | Invoke-Expression
+ (in $PROFILE)
+
+The packages install the bash one where bash-completion finds it. Both scripts
+ask `meshbay-cli __complete` for the candidates (see `complete.py`): actions,
+verbs, options, and the remote paths of the groups this machine is signed in to.
+"""
+
+from ..errors import USAGE, CliError
+from ..output import sentence
+from .context import Context
+
+HELP = "print the script that makes Tab complete meshbay-cli in bash or PowerShell"
+
+BASH = r'''# meshbay-cli completion for bash: eval "$(meshbay-cli completion bash)"
+_meshbay_cli() {
+ local cur=${COMP_WORDS[COMP_CWORD]} line files=0
+ local -a found=()
+ while IFS= read -r line; do
+ if [[ $line == __files__ ]]; then files=1; else found+=("$line"); fi
+ done < <(command meshbay-cli __complete bash "$COMP_CWORD" "${COMP_WORDS[@]}" 2>/dev/null)
+ COMPREPLY=("${found[@]}")
+ if (( files )); then
+ local f
+ while IFS= read -r f; do COMPREPLY+=("$f"); done < <(compgen -f -- "$cur")
+ fi
+ # Quote spaces and the like as for file names; no space after a folder.
+ compopt -o filenames 2>/dev/null
+ local c
+ for c in "${COMPREPLY[@]}"; do
+ if [[ $c == */ ]]; then compopt -o nospace 2>/dev/null; break; fi
+ done
+}
+complete -F _meshbay_cli meshbay-cli
+'''
+
+POWERSHELL = r'''# meshbay-cli completion for PowerShell:
+# meshbay-cli completion powershell | Out-String | Invoke-Expression
+Register-ArgumentCompleter -Native -CommandName 'meshbay-cli', 'meshbay-cli.exe' -ScriptBlock {
+ param($wordToComplete, $commandAst, $cursorPosition)
+ $elements = @($commandAst.CommandElements)
+ $words = @($elements | ForEach-Object { $_.Extent.Text })
+ $index = $words.Count
+ for ($i = 0; $i -lt $elements.Count; $i++) {
+ $e = $elements[$i].Extent
+ if ($cursorPosition -ge $e.StartOffset -and $cursorPosition -le $e.EndOffset) {
+ $index = $i
+ }
+ }
+ $found = @(& meshbay-cli __complete powershell $index @words 2>$null)
+ $quote = {
+ param($s)
+ if ($s -match '[\s''"`$&|;(){}@,#]') { "'" + ($s -replace "'", "''") + "'" } else { $s }
+ }
+ $item = [System.Management.Automation.CompletionResult]
+ $results = @()
+ foreach ($c in $found) {
+ if ($c -eq '__files__') {
+ $sep = [IO.Path]::DirectorySeparatorChar
+ $typed = $wordToComplete.Trim([char[]]"'`"")
+ $dir = Split-Path -Path $typed -Parent
+ Get-ChildItem -Path "$typed*" -ErrorAction SilentlyContinue |
+ ForEach-Object {
+ $p = if ($dir) { Join-Path $dir $_.Name } else { $_.Name }
+ if ($_.PSIsContainer) { $p += $sep }
+ $results += $item::new((& $quote $p), $p, 'ProviderItem', $p)
+ }
+ continue
+ }
+ $results += $item::new((& $quote $c), $c, 'ParameterValue', $c)
+ }
+ $results
+}
+'''
+
+SCRIPTS = {"bash": BASH, "powershell": POWERSHELL}
+
+
+def add_parser(sub, parent) -> None:
+ p = sub.add_parser("completion", parents=[parent], help=HELP, description=sentence(HELP)
+ + '. bash: eval "$(meshbay-cli completion bash)" in ~/.bashrc; '
+ "PowerShell: meshbay-cli completion powershell | Out-String | "
+ "Invoke-Expression in $PROFILE.")
+ p.add_argument("shell", choices=tuple(SCRIPTS), help="the shell")
+
+
+async def run(ctx: Context) -> int:
+ script = SCRIPTS.get(ctx.args.shell)
+ if script is None:
+ raise CliError(USAGE, "bash or powershell.")
+ print(script, end="")
+ return 0
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py b/packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py
new file mode 100644
index 0000000..de8cdcb
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py
@@ -0,0 +1,108 @@
+"""
+`mkdir`: make folders in a group, as the Files tab's "New folder" does.
+
+Any member may, where the root accepts uploads: organising where files go is
+not privileged on the node (removing a folder is — see `rm -r`). The parent
+must exist, unless --parents makes the missing ones too; with it a folder
+already there is not an error. Not -p, which is the passphrase everywhere in
+this program. Names follow the node's rule for uploads, checked here
+first so nothing is sent that it would refuse.
+"""
+
+import json
+
+from ..client.groups import my_groups
+from ..client.names import same_name, upload_name_problem
+from ..client.paths import handle, parse, resolve_group
+from ..client.pool import Pool
+from ..client.transfers import TransferError, create_dir
+from ..client.tree import Tree
+from ..errors import NOT_FOUND, PARTIAL, REFUSED, USAGE, CliError
+from ..output import err, safe, sentence
+from .context import Context
+
+HELP = "make folders in a group"
+
+
+def add_parser(sub, parent) -> None:
+ p = sub.add_parser("mkdir", parents=[parent], help=HELP, description=sentence(HELP)
+ + ", where its root accepts uploads.")
+ p.add_argument("paths", nargs="+", metavar="PATH",
+ help="group[@owner]/root/.../new-folder")
+ p.add_argument("--parents", action="store_true",
+ help="make missing parent folders too; a folder already there is "
+ "not an error (not -p: that is --password)")
+
+
+async def run(ctx: Context) -> int:
+ await ctx.hub.check_version()
+ account = ctx.account()
+ pool = Pool(account)
+ try:
+ return await _run(ctx, account, pool)
+ finally:
+ await pool.close()
+ await account.close()
+
+
+async def _run(ctx: Context, account, pool: Pool) -> int:
+ a = ctx.args
+ groups = await my_groups(account)
+ plans = [] # (session, group, [(parent, name), ...])
+ for text in a.paths:
+ remote = parse(text)
+ if remote is None or len(remote.parts) < 2:
+ raise CliError(USAGE, "Name the folder to make inside a root: "
+ "group[@owner]/root/folder")
+ group = resolve_group(groups, remote.group)
+ session = await pool.session(group)
+ tree = Tree.from_index(await session.index())
+ shown = f"{handle(group)}/{remote.inner}"
+ root = tree.roots.get(remote.parts[0])
+ if root is None:
+ raise CliError(NOT_FOUND, f"No root {safe(remote.parts[0])} in "
+ f"{safe(handle(group))}.")
+ if not root.get("available", True):
+ raise CliError(REFUSED, f"{safe(shown)}: its drive is disconnected from the node.")
+ if not root.get("writable"):
+ raise CliError(REFUSED, f"{safe(shown)}: the root is read-only — its operator "
+ "does not accept uploads there.")
+ steps = []
+ for depth in range(2, len(remote.parts) + 1):
+ parent, name = "/".join(remote.parts[:depth - 1]), remote.parts[depth - 1]
+ if (why := upload_name_problem(name, is_dir=True)):
+ raise CliError(REFUSED, f"{safe(name)}: {why}")
+ path = f"{parent}/{name}"
+ if tree.is_dir(path):
+ continue
+ # Folders are told apart without case, as the node does.
+ clash = next((i for i in tree.children(parent) if same_name(i.name, name)),
+ None) if tree.is_dir(parent) else None
+ if clash is not None:
+ raise CliError(REFUSED, f"{safe(handle(group))}/{safe(clash.path)} is already "
+ "there" + ("" if clash.is_dir else " (a file)") + ".")
+ if depth < len(remote.parts) and not a.parents:
+ raise CliError(NOT_FOUND, f"No folder {safe(handle(group))}/{safe(path)} "
+ "(--parents makes it too).")
+ steps.append((parent, name))
+ if not steps and not a.parents:
+ raise CliError(REFUSED, f"{safe(shown)} is already there.")
+ plans.append((session, group, steps))
+
+ made, failed = [], []
+ for session, group, steps in plans:
+ for parent, name in steps:
+ try:
+ path = await create_dir(session, parent, name)
+ except TransferError as e:
+ failed.append(f"{handle(group)}/{parent}/{name}: {e.message}")
+ break # the folders below it cannot follow
+ made.append(f"{handle(group)}/{path}")
+ if a.json:
+ print(json.dumps({"made": made, "failed": failed}, ensure_ascii=False))
+ elif not a.quiet:
+ for m in made:
+ err(f"Made {safe(m)}/")
+ for f in failed:
+ err(f"meshbay-cli: {safe(f)}")
+ return PARTIAL if failed else 0
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/rm.py b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py
index 6f5e869..7f4a5c6 100644
--- a/packages/meshbay-cli/src/meshbay_cli/commands/rm.py
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py
@@ -1,12 +1,18 @@
"""
-`rm`: delete files from groups. Asked first; `--yes` answers for a script.
+`rm`: delete files from groups, and with -r folders and all they hold. Asked
+first; `--yes` answers for a script.
Paths and patterns are resolved against each group's index, as `get` resolves
them, and the whole run is checked before anything is asked or sent: a file
this account may not delete — not uploaded by it, and this machine's key not
-the node's operator — refuses the run (exit 8), as a folder does. The node
-checks again on every file; one it refuses is reported and the others go on
-(exit 4).
+the node's operator — refuses the run (exit 8), as a folder does without -r.
+
+With -r, a folder is everything below it: its files first, then its folders,
+deepest first. The node removes only empty folders, and only for its operator's
+key, so -r on a folder is the operator's, and the confirmation counts every
+file before one is deleted. A group's root is never removed. The node checks
+again on every file and folder; one it refuses is reported and the others go
+on (exit 4) — a folder still holding a file it refused stays.
"""
import json
@@ -14,13 +20,14 @@ import json
from ..client.groups import my_groups
from ..client.paths import handle, parse, resolve_group
from ..client.pool import Pool
-from ..client.remove import delete_file, may_delete
+from ..client.remove import delete_dir, delete_file, may_delete
from ..client.tree import Tree
from ..errors import NODE, NOT_FOUND, PARTIAL, REFUSED, USAGE, CliError
-from ..output import confirm, err, safe, sentence
+from ..output import confirm, err, human_size, safe, sentence
from .context import Context
-HELP = "delete files you uploaded (or any, from the node's operator key)"
+HELP = ("delete files you uploaded (or any, from the node's operator key); with -r, "
+ "folders too, from the operator's key")
# How many names the confirmation shows before it counts the rest.
SHOWN = 10
@@ -32,6 +39,8 @@ def add_parser(sub, parent) -> None:
"quote them. Being the group's owner is not enough: the node "
"lets its operator's key, and the uploader's devices, delete.")
p.add_argument("paths", nargs="+", metavar="PATH", help="group[@owner]/root/.../file")
+ p.add_argument("-r", "-R", dest="recursive", action="store_true",
+ help="folders, with everything in them (the node's operator only)")
p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
@@ -51,7 +60,8 @@ async def _run(ctx: Context, account, pool: Pool) -> int:
groups = await my_groups(account)
trees: dict[str, Tree] = {}
targets = [] # (session, entry, shown)
- missing, folders, not_yours = [], [], []
+ dirs = [] # (session, path, shown)
+ missing, folders, not_yours, roots, not_operator = [], [], [], [], []
for text in a.paths:
remote = parse(text)
if remote is None or not remote.parts:
@@ -69,30 +79,65 @@ async def _run(ctx: Context, account, pool: Pool) -> int:
for path in found:
shown = f"{handle(group)}/{path}"
entry = tree.file(path)
- if entry is None:
+ if entry is not None:
+ if not may_delete(session, entry, account.user_id):
+ not_yours.append(shown)
+ elif all(e["id"] != entry["id"] for _, e, _ in targets):
+ targets.append((session, entry, shown))
+ continue
+ if not a.recursive:
folders.append(shown)
- elif not may_delete(session, entry, account.user_id):
- not_yours.append(shown)
- elif all(e["id"] != entry["id"] for _, e, _ in targets):
- targets.append((session, entry, shown))
+ elif "/" not in path:
+ roots.append(shown)
+ elif session.role != "operator":
+ not_operator.append(shown)
+ elif not tree.root_available(path):
+ raise CliError(REFUSED, f"{safe(shown)} is on a drive the node does not see "
+ "now; nothing was deleted.")
+ else:
+ for folder, kids in tree.walk(path):
+ if all(d[1] != folder or d[0] is not session for d in dirs):
+ dirs.append((session, folder, f"{handle(group)}/{folder}"))
+ for kid in kids:
+ if not kid.is_dir and all(e["id"] != kid.entry["id"]
+ for _, e, _ in targets):
+ targets.append((session, kid.entry,
+ f"{handle(group)}/{kid.path}"))
if missing:
raise CliError(NOT_FOUND, "Nothing in your groups matches: "
+ ", ".join(safe(m) for m in missing))
if folders:
- raise CliError(USAGE, "rm deletes files; these are folders: "
- + ", ".join(safe(f) for f in folders))
+ raise CliError(USAGE, "These are folders; -r deletes them with everything in "
+ "them: " + ", ".join(safe(f) for f in folders))
+ if roots:
+ raise CliError(REFUSED, "A group's root is not deleted here — its operator removes "
+ "it from the node: " + ", ".join(safe(r) for r in roots))
+ if not_operator:
+ raise CliError(REFUSED, "Not deleted — removing a folder is the node operator's, "
+ "and this machine's key is not:\n "
+ + "\n ".join(safe(n) for n in not_operator))
if not_yours:
raise CliError(REFUSED, "Not deleted — you did not upload these, and this machine's "
"key is not the node's operator:\n "
+ "\n ".join(safe(n) for n in not_yours))
names = [shown for _, _, shown in targets]
- listed = "\n ".join(safe(n) for n in names[:SHOWN])
- more = f"\n ... and {len(names) - SHOWN} more" if len(names) > SHOWN else ""
- confirm(f"Delete {len(names)} file{'s' if len(names) != 1 else ''} for every member "
- f"of the group?\n {listed}{more}\n", a.yes)
+ # The deepest first: each one is empty by the time it is asked for.
+ dirs.sort(key=lambda d: d[1].count("/"), reverse=True)
+ tops = [shown for _, path, shown in dirs
+ if not any(path.startswith(other + "/") for _, other, _ in dirs)]
+ shown_names = tops + [n for n in names if not any(n.startswith(t + "/") for t in tops)]
+ listed = "\n ".join(safe(n) + ("/" if n in tops else "") for n in shown_names[:SHOWN])
+ more = (f"\n ... and {len(shown_names) - SHOWN} more"
+ if len(shown_names) > SHOWN else "")
+ size = sum(int(e.get("size", 0)) for _, e, _ in targets)
+ what = f"{len(names)} file{'s' if len(names) != 1 else ''}"
+ if dirs:
+ what += (f" ({human_size(size)}) and {len(dirs)} folder"
+ f"{'s' if len(dirs) != 1 else ''}")
+ confirm(f"Delete {what} for every member of the group?\n {listed}{more}\n", a.yes)
- deleted, failed = [], []
+ deleted, removed, failed = [], [], []
for session, entry, shown in targets:
try:
await delete_file(session, entry["id"])
@@ -101,11 +146,22 @@ async def _run(ctx: Context, account, pool: Pool) -> int:
if e.code not in (NODE, REFUSED):
raise
failed.append(f"{shown}: {e.message}")
+ for session, path, shown in dirs:
+ try:
+ await delete_dir(session, path)
+ removed.append(shown)
+ except CliError as e:
+ if e.code not in (NODE, REFUSED):
+ raise
+ failed.append(f"{shown}/: {e.message}")
if a.json:
- print(json.dumps({"deleted": deleted, "failed": failed}, ensure_ascii=False))
+ print(json.dumps({"deleted": deleted, "folders_removed": removed, "failed": failed},
+ ensure_ascii=False))
elif not a.quiet:
n = len(deleted)
- err(f"Deleted {n} file{'s' if n != 1 else ''}.")
+ err(f"Deleted {n} file{'s' if n != 1 else ''}"
+ + (f" and {len(removed)} folder{'s' if len(removed) != 1 else ''}"
+ if dirs else "") + ".")
for f in failed:
err(f"meshbay-cli: {safe(f)}")
return PARTIAL if failed else 0
diff --git a/packages/meshbay-cli/src/meshbay_cli/complete.py b/packages/meshbay-cli/src/meshbay_cli/complete.py
new file mode 100644
index 0000000..ac590ce
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/complete.py
@@ -0,0 +1,319 @@
+"""
+Shell completion: what the word under the cursor may become.
+
+`meshbay-cli __complete SHELL CWORD WORD...` prints one candidate per line, for
+the scripts `completion` prints. Actions, verbs and options come from the
+parser, so they cannot drift from it. Remote paths — `group@owner/root/folder/`
+— come from the hub (the groups) and the node (a group's folders and files),
+for the account this machine remembers only: nothing is asked, no passphrase
+and no device approval, and a node is opened the way that never makes keys
+(`session.Rewrap`). What a group holds is cached for two minutes, so the next
+Tab is instant. Anything that fails is no candidate, never an error on screen.
+
+`__files__` on a line of its own asks the shell for its own file completion
+(a local path: `put`'s sources, `-o`).
+"""
+
+import argparse
+import asyncio
+import contextlib
+import json
+import logging
+import os
+import sys
+import time
+
+FILES = "__files__"
+CACHE_TTL = 120
+DEADLINE = 8.0
+GLOBAL_VALUES = {"--hub", "-u", "--user", "-p", "--password", "--password-file"}
+FILE_OPTIONS = {"--password-file", "-o", "--new-password-file", "--recovery-key-file"}
+
+
+def unquote(word: str, shell: str) -> str:
+ """The word as typed, its quoting taken off: bash's backslashes and quotes,
+ PowerShell's quotes and doubled single quote."""
+ if len(word) >= 1 and word[0] == "'":
+ body = word[1:-1] if len(word) > 1 and word.endswith("'") else word[1:]
+ return body.replace("''", "'") if shell == "powershell" else body
+ if len(word) >= 1 and word[0] == '"':
+ body = word[1:-1] if len(word) > 1 and word.endswith('"') else word[1:]
+ return body.replace('\\"', '"').replace('`"', '"')
+ if shell == "bash":
+ out, escaped = [], False
+ for c in word:
+ if escaped or c != "\\":
+ out.append(c)
+ escaped = False
+ else:
+ escaped = True
+ return "".join(out)
+ return word
+
+
+def _subparsers(parser: argparse.ArgumentParser) -> dict[str, argparse.ArgumentParser]:
+ sub = next(a for a in parser._actions if isinstance(a, argparse._SubParsersAction))
+ return dict(sub.choices)
+
+
+def _options(parser: argparse.ArgumentParser) -> dict[str, argparse.Action]:
+ return {s: a for a in parser._actions for s in a.option_strings}
+
+
+def plan(words: list[str], cword: int, parser: argparse.ArgumentParser):
+ """
+ What kind of word is under the cursor: ("words", [candidates]), ("files",),
+ ("remote", action), ("groups",), ("invitations",), ("pins",), or ("none",).
+ `words[0]` is the program.
+ """
+ from .__main__ import ACTIONS
+ subs = _subparsers(parser)
+ current = words[cword] if cword < len(words) else ""
+ action, positionals, i = None, [], 1
+ top = _options(parser)
+ while i < cword:
+ w = words[i]
+ opts = _options(subs[action]) if action else top
+ if w.startswith("-") and w != "-":
+ a = opts.get(w.split("=", 1)[0])
+ if a is not None and a.nargs != 0 and "=" not in w:
+ i += 1 # its value
+ elif action is None and w in ACTIONS:
+ action = w
+ elif action is not None:
+ positionals.append(w)
+ i += 1
+
+ prev = words[cword - 1] if cword - 1 >= 1 else ""
+ opts = _options(subs[action]) if action else top
+ taking = opts.get(prev)
+ if prev.startswith("-") and taking is not None and taking.nargs != 0:
+ if taking.choices:
+ return ("words", [str(c) for c in taking.choices])
+ return ("files",) if prev in FILE_OPTIONS else ("none",)
+
+ if current.startswith("-"):
+ names = sorted(opts) if action else sorted(top)
+ return ("words", [n for n in names if n.startswith("--") or len(n) == 2])
+ if action is None:
+ return ("words", list(ACTIONS))
+
+ slots = [a for a in subs[action]._actions if not a.option_strings]
+ slot = _slot(slots, len(positionals), action)
+ if slot is None:
+ return ("none",)
+ if slot.choices:
+ return ("words", [str(c) for c in slot.choices])
+ meta = slot.metavar or ""
+ if meta == "ACTION":
+ return ("words", list(ACTIONS))
+ if meta == "SOURCE":
+ return ("files+remote", action)
+ if meta in ("GROUP",):
+ return ("groups",)
+ if meta in ("INVITATION", "INVITATION|LINK"):
+ return ("invitations",)
+ if action == "trust":
+ return ("pins",)
+ if meta in ("PATH", "DEST") or slot.dest == "path":
+ return ("remote", action)
+ return ("none",)
+
+
+def _slot(slots: list[argparse.Action], n: int, action: str):
+ """The positional the n-th word fills. `put SOURCE... DEST`: every word is a
+ source or the destination, and either may be what is being typed."""
+ if action == "put":
+ return slots[0]
+ for a in slots:
+ if a.nargs in ("+", "*"):
+ return a
+ if n == 0:
+ return a
+ n -= 1
+ return None
+
+
+# ── The account's groups, and what they hold ─────────────────────────────────
+
+def _cache_path():
+ from .client.store import data_dir
+ return data_dir() / "completion-cache.json"
+
+
+def _cache_read() -> dict:
+ try:
+ return json.loads(_cache_path().read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return {}
+
+
+def _cache_write(cache: dict) -> None:
+ with contextlib.suppress(OSError):
+ path = _cache_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(".tmp")
+ tmp.write_text(json.dumps(cache), encoding="utf-8")
+ os.replace(tmp, path)
+
+
+def _fresh(entry: dict | None) -> bool:
+ return bool(entry) and time.time() - entry.get("at", 0) < CACHE_TTL
+
+
+async def _account():
+ from .client.account import Account, no_passphrase
+ from .client.hub import Hub
+ from .client.store import Store
+ store = Store()
+ stored = store.peek()
+ if not stored.get("hub"):
+ return None
+ hub = Hub(os.environ.get("MESHBAY_HUB") or stored["hub"])
+ try:
+ account = Account.open(hub, None, no_passphrase, store)
+ except Exception:
+ await hub.aclose()
+ return None
+ if not account.remembered:
+ await hub.aclose()
+ return None
+ return account
+
+
+async def _groups(account, cache: dict) -> list[dict]:
+ key = f"groups:{account.hub.url}:{account.username}"
+ if _fresh(cache.get(key)):
+ return cache[key]["rows"]
+ from .client.groups import my_groups
+ rows = [{k: g.get(k) for k in ("id", "name", "owner_username")}
+ for g in await my_groups(account)]
+ cache[key] = {"at": time.time(), "rows": rows}
+ return rows
+
+
+async def _tree(account, group: dict, cache: dict):
+ from .client.session import Rewrap, connect
+ from .client.tree import Tree
+ key = f"index:{group['id']}"
+ if not _fresh(cache.get(key)):
+ nodes = await account.call(account.hub.group_nodes, group["id"])
+ nodes = sorted(nodes, key=lambda n: account.pin(n["node_id"]) is None)
+ index = None
+ for node in nodes:
+ try:
+ session = await connect(account, group["id"], node, rewrap=Rewrap())
+ except Exception:
+ continue
+ try:
+ index = await session.index()
+ finally:
+ await session.close()
+ break
+ if index is None:
+ return None
+ cache[key] = {"at": time.time(), "index": {
+ "roots": [{k: r.get(k) for k in ("name", "available", "writable")}
+ for r in index.get("roots", [])],
+ "dirs": index.get("dirs", []),
+ "entries": [{k: e.get(k) for k in ("id", "name", "path", "size")}
+ for e in index.get("entries", [])]}}
+ return Tree.from_index(cache[key]["index"])
+
+
+async def remote(prefix: str, action: str) -> list[str]:
+ from .client.paths import encode_name, handle, resolve_group
+ account = await _account()
+ if account is None:
+ return []
+ cache = _cache_read()
+ try:
+ groups = await _groups(account, cache)
+ if "/" not in prefix:
+ low = prefix.lower()
+ return [handle(g) + "/" for g in groups if handle(g).lower().startswith(low)]
+ spec, _, rest = prefix.partition("/")
+ group = resolve_group(groups, spec.replace("%2F", "/").replace("%25", "%"))
+ tree = await _tree(account, group, cache)
+ if tree is None:
+ return []
+ folder, _, partial = rest.rpartition("/")
+ folder_path = folder.replace("%2F", "/").replace("%25", "%")
+ kids = tree.children(folder_path) if (not folder or tree.is_dir(folder_path)) else []
+ base = f"{spec}/{folder}/" if folder else f"{spec}/"
+ out = []
+ for kid in kids:
+ if not kid.name.lower().startswith(partial.lower()):
+ continue
+ if not kid.is_dir and action in ("mkdir", "put"):
+ continue # a destination is a folder
+ out.append(base + encode_name(kid.name) + ("/" if kid.is_dir else ""))
+ return sorted(out, key=str.lower)
+ finally:
+ _cache_write(cache)
+ await account.close()
+ await account.hub.aclose()
+
+
+async def _invitations() -> list[str]:
+ from .client.paths import handle
+ account = await _account()
+ if account is None:
+ return []
+ try:
+ return [handle(g) for g in await account.call(account.hub.invitations)]
+ finally:
+ await account.close()
+ await account.hub.aclose()
+
+
+def _pins() -> list[str]:
+ from .client.store import Store
+ store = Store()
+ with contextlib.suppress(Exception):
+ if store.load():
+ return sorted(store.state.get("pins", {}))
+ return []
+
+
+def candidates(shell: str, cword: int, words: list[str]) -> list[str]:
+ from .__main__ import build_parser
+ words = [unquote(w, shell) for w in words]
+ current = words[cword] if cword < len(words) else ""
+ kind = plan(words, cword, build_parser())
+
+ def matching(items):
+ return [c for c in items if c.startswith(current)]
+
+ if kind[0] == "words":
+ return matching(kind[1])
+ if kind[0] == "files":
+ return [FILES]
+ if kind[0] == "pins":
+ return matching(_pins())
+ if kind[0] in ("remote", "files+remote", "groups", "invitations"):
+ async def go():
+ if kind[0] == "invitations":
+ return matching(await _invitations())
+ found = await remote(current, kind[1] if len(kind) > 1 else "")
+ if kind[0] == "groups":
+ return [c[:-1] for c in found if c.count("/") == 1]
+ return found
+ try:
+ found = asyncio.run(asyncio.wait_for(go(), DEADLINE))
+ except Exception:
+ found = []
+ return ([FILES] if kind[0] == "files+remote" else []) + found
+ return []
+
+
+def main(argv: list[str]) -> int:
+ """`__complete SHELL CWORD WORD...`: never fails, never writes to stderr."""
+ logging.disable(logging.CRITICAL)
+ try:
+ shell, cword, words = argv[0], int(argv[1]), argv[2:]
+ out = candidates(shell, cword, words)
+ except Exception:
+ out = []
+ sys.stdout.write("".join(c + "\n" for c in out))
+ return 0
diff --git a/packages/meshbay-cli/src/meshbay_cli/manpage.py b/packages/meshbay-cli/src/meshbay_cli/manpage.py
index c4710f3..b2e4926 100644
--- a/packages/meshbay-cli/src/meshbay_cli/manpage.py
+++ b/packages/meshbay-cli/src/meshbay_cli/manpage.py
@@ -171,6 +171,25 @@ says so once.
ends the session on the hub, retires this machine's device key and erases
everything stored.
.PP
+.B account register
+creates an account: a new passphrase, typed twice, must have 12 characters
+and be hard enough to guess, as in the application.
+It prints the account's recovery key once \(em MeshBay keeps it nowhere \(em
+and asks for the code e-mailed to the address, or
+.B account verify
+takes it later.
+.B account passphrase
+changes a known passphrase: the copy of your keys each node keeps for browsers
+is sealed again, the same keys, under the new one, and only then is the hub
+told; if the hub refuses, the copies are set back.
+A node out of reach keeps its copy as it was, and this machine seals it again
+the next time it connects there.
+.B account reset
+is for a forgotten passphrase: a code e-mailed to the account's address, a new
+passphrase, and, if you have it, the recovery key, which gives back your keys
+on each node; without it, a group whose keys this machine does not keep needs
+its operator.
+.PP
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.
@@ -320,7 +339,9 @@ the members whose messages were read, the browser access setting),
.B store.key
only where the system has no key storage, and
.B interactive.log
-(the interactive mode's log).
+(the interactive mode's log) and
+.B completion\-cache.json
+(the groups and what they hold, as Tab completion last read them; two minutes).
.TP
\fIname\fB.meshbay\-part\fR, \fIname\fB.meshbay\-part.json\fR
A download in progress, next to where the file lands.
@@ -348,6 +369,13 @@ nor
.B \-\-password
is given.
.TP
+.B MESHBAY_NEW_PASSWORD
+The new passphrase for
+.BR "account passphrase" ,
+when
+.B \-\-new\-password\-file
+is not given.
+.TP
.B MESHBAY_CLI_HOME
The data directory, instead of the default (see FILES).
.TP
diff --git a/packages/meshbay-cli/tests/fake_node.py b/packages/meshbay-cli/tests/fake_node.py
index f9c552c..552bd39 100644
--- a/packages/meshbay-cli/tests/fake_node.py
+++ b/packages/meshbay-cli/tests/fake_node.py
@@ -23,7 +23,7 @@ from fake_hub import USER_ID
from meshbay_cli.client.keys import Identity
from meshbay_cli.client.session import GroupSession, NodeRefusal
from meshbay_common import chatbox
-from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript
+from meshbay_common.adminop import OP_DIR_DELETE, OP_FILE_DELETE, admin_transcript
from meshbay_common.device import (
device_add_transcript,
device_code_hash,
@@ -322,11 +322,38 @@ class FakeChannel:
"subject": msg["file_id"], "nonce": base64.b64encode(nonce).decode(),
"ts": 1_700_000_000, "node_pk": NODE_PK, "group_id": GROUP_ID}
+ def _dir_delete(self, msg):
+ """Empty folders only, the operator's only, never a root — the node's rule."""
+ n, path = self.node, msg["dir"].strip("/")
+ if path not in n.dirs or "/" not in path:
+ return {"type": "error", "detail": "Invalid directory"}
+ if any(d.startswith(path + "/") for d in n.dirs) or \
+ any(e["path"] == path or e["path"].startswith(path + "/")
+ for e in n.files.values()):
+ return {"type": "error", "detail": "Directory is not empty"}
+ if n.role != "operator":
+ return {"type": "error", "detail": "No authorized key for deletion"}
+ op_id, nonce = secrets.token_hex(8), secrets.token_bytes(32)
+ n.admin_ops[op_id] = {"op": OP_DIR_DELETE, "subject": path, "nonce": nonce,
+ "ts": 1_700_000_000}
+ return {"type": "admin_challenge", "op_id": op_id, "op": OP_DIR_DELETE,
+ "subject": path, "nonce": base64.b64encode(nonce).decode(),
+ "ts": 1_700_000_000, "node_pk": NODE_PK, "group_id": GROUP_ID}
+
def _admin_response(self, msg):
n = self.node
op = n.admin_ops.pop(msg["op_id"], None)
if op is None:
return {"type": "error", "detail": "No pending admin operation"}
+ if op.get("op") == OP_DIR_DELETE:
+ transcript = admin_transcript(op=OP_DIR_DELETE, node_pk_b64=NODE_PK,
+ group_id=GROUP_ID, subject=op["subject"],
+ nonce=op["nonce"], ts=op["ts"])
+ if not (_signed_by_a_device(n, transcript, msg["signature"])
+ and n.role == "operator"):
+ return {"type": "error", "detail": "Signature verification failed"}
+ n.dirs.discard(op["subject"])
+ return {"type": "dir_delete_ack", "dir": op["subject"]}
entry = n.files.get(op["subject"])
transcript = admin_transcript(op=OP_FILE_DELETE, node_pk_b64=NODE_PK,
group_id=GROUP_ID, subject=op["subject"],
diff --git a/packages/meshbay-cli/tests/test_account_flows.py b/packages/meshbay-cli/tests/test_account_flows.py
new file mode 100644
index 0000000..464b0d6
--- /dev/null
+++ b/packages/meshbay-cli/tests/test_account_flows.py
@@ -0,0 +1,285 @@
+"""
+The account's keys on the nodes across a new passphrase, and what `account
+passphrase` does in which order: the copies first, the hub only then, the
+copies set back if the hub refuses — never an account whose nodes and hub
+disagree on the passphrase.
+"""
+
+import asyncio
+import base64
+import types
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+from fake_hub import PASSPHRASE, USER_ID, USERNAME, FakeHub
+from meshbay_cli import __main__ as cli
+from meshbay_cli.client import rewrap, session
+from meshbay_cli.client.account import Account
+from meshbay_cli.client.hub import HubError
+from meshbay_cli.client.keys import (
+ Identity,
+ b64,
+ bundle_master_key,
+ opens_under,
+ passphrase_half,
+ seal_bundle,
+ seal_recovery,
+ unb64,
+)
+from meshbay_cli.client.store import Store
+from meshbay_cli.commands import account as account_cmd
+from meshbay_cli.commands import context
+
+NODE = base64.b64encode(b"\x05" * 32).decode()
+M_OLD, M_NEW, M_ELSEWHERE = b"\x01" * 32, b"\x02" * 32, b"\x03" * 32
+
+
+def _keys() -> dict:
+ return Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()).to_pkcs8()
+
+
+def _sealed(keys: dict, master: bytes) -> str:
+ return seal_bundle(keys, master=master, pepper_version=1, user_id=USER_ID, node_pk=NODE)
+
+
+class Ch:
+ def __init__(self):
+ self.sent: list[dict] = []
+
+ async def request(self, msg, *expect, **kw):
+ self.sent.append(msg)
+ return {"type": "ack"}
+
+
+def _account(master: bytes, previous=(), setting=None) -> Account:
+ acc = Account(None, USERNAME, lambda: PASSPHRASE)
+ acc.state = {"user_id": USER_ID, **({"browser_access": setting} if setting else {})}
+ acc.secrets = {"master": b64(master), "previous_masters": [b64(m) for m in previous]}
+ return acc
+
+
+# ── What a connection leaves on the node ─────────────────────────────────────
+
+async def test_a_copy_under_a_passphrase_changed_here_is_carried_same_keys():
+ browsers, mine = _keys(), Identity(Ed25519PrivateKey.generate(),
+ X25519PrivateKey.generate())
+ ch = Ch()
+ await session._settle_bundle(_account(M_NEW, [M_OLD]), ch, mine, NODE,
+ _sealed(browsers, M_OLD))
+ assert len(ch.sent) == 1
+ assert opens_under(ch.sent[0]["bundle_enc"], M_NEW, user_id=USER_ID,
+ node_pk=NODE) == browsers
+
+
+async def test_a_copy_under_a_passphrase_set_elsewhere_is_left_alone(caplog):
+ """Rewriting it under this machine's older `M` would lock the browsers out."""
+ ch = Ch()
+ mine = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate())
+ await session._settle_bundle(_account(M_OLD, setting="on"), ch, mine, NODE,
+ _sealed(_keys(), M_ELSEWHERE))
+ assert ch.sent == []
+ assert "does not know" in caplog.text
+
+
+@pytest.mark.parametrize("setting, bundle, expect", [
+ ("on", "", "keypair_bundle_store"),
+ ("off", "x", "keypair_bundle_delete"),
+ (None, "", None),
+])
+async def test_browser_access_still_decides_the_rest(setting, bundle, expect):
+ ch = Ch()
+ mine = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate())
+ if bundle:
+ bundle = _sealed(_keys(), M_NEW)
+ await session._settle_bundle(_account(M_NEW, setting=setting), ch, mine, NODE, bundle)
+ assert [m["type"] for m in ch.sent] == ([expect] if expect else [])
+
+
+# ── Carrying the copies ──────────────────────────────────────────────────────
+
+class FakeSession:
+ def __init__(self, held: dict, identity: Identity | None = None):
+ self.held, self.node_pk, self.ch = held, NODE, Ch()
+ self.identity = identity or Identity(Ed25519PrivateKey.generate(),
+ X25519PrivateKey.generate())
+
+ async def close(self):
+ pass
+
+
+def _hub(nodes_by_group: dict):
+ async def group_nodes(token, gid):
+ return nodes_by_group[gid]
+ return types.SimpleNamespace(group_nodes=group_nodes)
+
+
+def _carry_world(monkeypatch, acc: Account, nodes_by_group: dict, sessions: dict):
+ acc.hub = _hub(nodes_by_group)
+
+ async def call(fn, *args):
+ return await fn("t", *args)
+ monkeypatch.setattr(acc, "call", call)
+ opened = []
+
+ async def connect(account, gid, node, join_code=None, *, rewrap=None):
+ opened.append(node["pk_node"])
+ s = sessions[node["pk_node"]]
+ if isinstance(s, Exception):
+ raise s
+ return s
+ monkeypatch.setattr(rewrap, "connect", connect)
+ return opened
+
+
+async def test_each_copy_is_sealed_again_once_per_node(monkeypatch):
+ browsers = _keys()
+ acc = _account(M_OLD)
+ s = FakeSession({"bundle_enc": _sealed(browsers, M_OLD)})
+ opened = _carry_world(monkeypatch, acc, {
+ "g1": [{"pk_node": "n1"}], "g2": [{"pk_node": "n1"}], "g3": [],
+ "g4": [{"pk_node": "n2"}]}, {"n1": s, "n2": session.NothingHere()})
+ groups = [{"id": g, "name": g, "owner_username": "bob"} for g in ("g1", "g2", "g3", "g4")]
+ out = await rewrap.carry(acc, groups, master=M_NEW, pepper_version=1)
+ assert opened == ["n1", "n2"] # n1 once, for two groups
+ assert out.done == ["n1"] and out.unreachable == ["g3@bob"] and out.nothing == ["g4@bob"]
+ sent = s.ch.sent[0]
+ assert opens_under(sent["bundle_enc"], M_NEW, user_id=USER_ID, node_pk=NODE) == browsers
+ assert "bundle_enc_recovery" not in sent # the node keeps the one it has
+
+
+async def test_after_a_reset_the_recovery_copy_gives_the_keys_back(monkeypatch):
+ browsers, rkey = _keys(), b"\x09" * 32
+ acc = _account(M_NEW) # the reset's: opens nothing old
+ s = FakeSession({"bundle_enc": _sealed(browsers, M_OLD),
+ "bundle_enc_recovery": seal_recovery(browsers, rkey=rkey,
+ user_id=USER_ID, node_pk=NODE)})
+ _carry_world(monkeypatch, acc, {"g1": [{"pk_node": "n1"}]}, {"n1": s})
+ out = await rewrap.carry(acc, [{"id": "g1", "name": "g1"}], master=M_NEW,
+ pepper_version=1, recovery=rkey)
+ assert out.done == ["n1"]
+ sent = s.ch.sent[0]
+ assert opens_under(sent["bundle_enc"], M_NEW, user_id=USER_ID, node_pk=NODE) == browsers
+ assert sent["bundle_enc_recovery"]
+
+
+async def test_a_copy_nothing_opens_is_left_as_it_is_unless_this_machine_leaves_its_own(
+ monkeypatch):
+ acc = _account(M_NEW)
+ held = {"bundle_enc": _sealed(_keys(), M_OLD)}
+ s = FakeSession(dict(held))
+ _carry_world(monkeypatch, acc, {"g1": [{"pk_node": "n1"}]}, {"n1": s})
+ out = await rewrap.carry(acc, [{"id": "g1", "name": "g1"}], master=M_NEW,
+ pepper_version=1)
+ assert out.done == [] and "left as it is" in out.failed[0] and s.ch.sent == []
+
+ s = FakeSession(dict(held))
+ _carry_world(monkeypatch, acc, {"g1": [{"pk_node": "n1"}]}, {"n1": s})
+ out = await rewrap.carry(acc, [{"id": "g1", "name": "g1"}], master=M_NEW,
+ pepper_version=1, own_keys=True)
+ assert out.done == ["n1"]
+ assert opens_under(s.ch.sent[0]["bundle_enc"], M_NEW, user_id=USER_ID,
+ node_pk=NODE) == s.identity.to_pkcs8()
+
+
+# ── `account passphrase`: the order of things ────────────────────────────────
+
+NEW = "a new passphrase that is long enough"
+
+
+@pytest.fixture
+def hub(monkeypatch):
+ fake = FakeHub()
+ calls: list[str] = []
+ fake.calls = calls
+ fake.refuse_change = False
+
+ async def bundle_pepper(token, ak):
+ fake._auth(token)
+ if ak != fake.ak:
+ raise HubError(403, "Passphrase does not match")
+ calls.append("pepper")
+ return {"bundle_pepper": base64.b64encode(b"\x07" * 32).decode(),
+ "bundle_pepper_version": 1}
+
+ async def change_passphrase(token, old, new):
+ fake._auth(token)
+ calls.append("hub")
+ if fake.refuse_change:
+ raise HubError(429, "Too many requests")
+ fake.ak = new
+ return fake._session()
+
+ async def my_groups(token):
+ return [{"id": "g1", "name": "Family", "owner_username": USERNAME}]
+ fake.bundle_pepper, fake.change_passphrase, fake.my_groups = (
+ bundle_pepper, change_passphrase, my_groups)
+ monkeypatch.setattr(context, "Hub", lambda url: fake)
+ return fake
+
+
+def _master(passphrase: str) -> bytes:
+ return bundle_master_key(passphrase_half(USERNAME, passphrase), b"\x07" * 32, USER_ID)
+
+
+@pytest.fixture
+def carried(monkeypatch, hub):
+ seen = []
+
+ async def carry(account, groups, *, master, pepper_version, only=None, **kw):
+ hub.calls.append("carry")
+ seen.append({"opens_with": account.master, "seals_with": master, "only": only})
+ return rewrap.Carried(done=["n1"])
+ monkeypatch.setattr(account_cmd, "carry", carry)
+ return seen
+
+
+def _login():
+ assert cli.main(["--hub", "https://hub.test", "-u", USERNAME, "--password",
+ PASSPHRASE, "login"]) == 0
+
+
+async def _stored_secrets() -> dict:
+ store = Store()
+ store.load()
+ return store.secrets
+
+
+def test_the_copies_move_first_and_the_hub_only_then(hub, carried, monkeypatch, capsys):
+ _login()
+ monkeypatch.setenv("MESHBAY_NEW_PASSWORD", NEW)
+ assert cli.main(["--password", PASSPHRASE, "account", "passphrase"]) == 0
+ assert hub.calls == ["pepper", "carry", "hub"]
+ assert carried[0]["opens_with"] == _master(PASSPHRASE)
+ assert carried[0]["seals_with"] == _master(NEW)
+ secrets = asyncio.run(_stored_secrets())
+ assert unb64(secrets["master"]) == _master(NEW)
+ assert [unb64(m) for m in secrets["previous_masters"]] == [_master(PASSPHRASE)]
+ assert "sealed again on 1 node" in capsys.readouterr().out
+
+
+def test_a_refused_change_sets_the_copies_back(hub, carried, monkeypatch, capsys):
+ _login()
+ hub.refuse_change = True
+ monkeypatch.setenv("MESHBAY_NEW_PASSWORD", NEW)
+ assert cli.main(["--password", PASSPHRASE, "account", "passphrase"]) == 8
+ assert hub.calls == ["pepper", "carry", "hub", "carry"]
+ back = carried[1]
+ assert back["opens_with"] == _master(NEW) and back["seals_with"] == _master(PASSPHRASE)
+ assert back["only"] == {"n1"}
+ assert unb64(asyncio.run(_stored_secrets())["master"]) == _master(PASSPHRASE)
+ assert "nothing was changed" in capsys.readouterr().err
+
+
+@pytest.mark.parametrize("old, new, says", [
+ ("wrong passphrase entirely", NEW, "does not match"),
+ (PASSPHRASE, "too short", "12 characters"),
+ (PASSPHRASE, PASSPHRASE, "is the current one"),
+])
+def test_nothing_moves_on_a_wrong_or_weak_passphrase(hub, carried, monkeypatch, capsys,
+ old, new, says):
+ _login()
+ monkeypatch.setenv("MESHBAY_NEW_PASSWORD", new)
+ assert cli.main(["--password", old, "account", "passphrase"]) != 0
+ assert "carry" not in hub.calls and "hub" not in hub.calls
+ assert says in capsys.readouterr().err
diff --git a/packages/meshbay-cli/tests/test_account_keys.py b/packages/meshbay-cli/tests/test_account_keys.py
new file mode 100644
index 0000000..e14f631
--- /dev/null
+++ b/packages/meshbay-cli/tests/test_account_keys.py
@@ -0,0 +1,135 @@
+"""
+What `account` computes on this side, held to what the page computes.
+
+The recovery key and the passphrase floor are run against the page's own
+`keyderive.js` and `auth-page.js` under node: a recovery copy sealed here must
+open in a browser and the other way round, or a reset would restore nothing;
+and a passphrase the page refuses must be refused here. The proof of work is
+solved against the hub's own issuer.
+"""
+
+import base64
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+from meshbay_cli.client.keys import (
+ Identity,
+ generate_recovery,
+ open_recovery,
+ recovery_bytes,
+ recovery_key,
+ recovery_mnemonic,
+ seal_recovery,
+)
+from meshbay_cli.client.passphrase import (
+ passphrase_bits,
+ passphrase_problem,
+ solve_captcha,
+)
+
+STATIC = (Path(__file__).resolve().parents[2] / "meshbay-hub" / "src" / "meshbay_hub"
+ / "static")
+needs_node = pytest.mark.skipif(shutil.which("node") is None or not STATIC.exists(),
+ reason="node or the page's scripts are unavailable")
+
+USER, UID, NODE = "alice-test", "u-1", base64.b64encode(b"\x05" * 32).decode()
+PASSPHRASES = ["", "short", "correct horse battery staple", "aaaaaaaaaaaaaaaa",
+ "123456789012345678", "MyPassword is long enough!", "Zé ünïcødé ✓ 🔐 2026",
+ "azerty-azerty-azerty", "Tr0ub4dor&3xyzzy", "x" * 40]
+
+_HARNESS = r"""
+const fs = require('fs'), webcrypto = require('crypto').webcrypto;
+global.self = global; global.window = global; global.crypto = webcrypto;
+eval(fs.readFileSync(process.argv[2], 'utf8'));
+const K = window.MeshBayKeys;
+const page = fs.readFileSync(process.argv[3], 'utf8');
+eval(page.match(/function passwordBits[\s\S]*?\n}\n/)[0]);
+const input = JSON.parse(fs.readFileSync(process.argv[4], 'utf8'));
+const b = (s) => Uint8Array.from(Buffer.from(s, 'base64'));
+(async () => {
+ const out = {bits: input.passphrases.map(passwordBits)};
+ const key = await K.deriveRecoveryKey(input.mnemonic, input.user);
+ out.opened = await K.decryptBundle(input.sealed, key, {userId: input.uid, nodePk: input.node});
+ const fresh = K.generateRecoveryKey();
+ const fkey = await K.deriveRecoveryKey(fresh.mnemonic, input.user);
+ out.js_mnemonic = fresh.mnemonic;
+ out.js_raw = fresh.rawB64;
+ out.js_sealed = await K.encryptBundle(b(input.keys.skEd), b(input.keys.skX), fkey,
+ {userId: input.uid, nodePk: input.node, pepperVersion: 0});
+ process.stdout.write(JSON.stringify(out));
+})();
+"""
+
+
+@pytest.fixture(scope="module")
+def page(tmp_path_factory):
+ d = tmp_path_factory.mktemp("parity")
+ keys = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()).to_pkcs8()
+ raw, mnemonic = generate_recovery()
+ sealed = seal_recovery(keys, rkey=recovery_key(raw, USER), user_id=UID, node_pk=NODE)
+ (d / "h.cjs").write_text(_HARNESS, encoding="utf-8")
+ (d / "in.json").write_text(json.dumps({
+ "passphrases": PASSPHRASES, "mnemonic": mnemonic, "user": USER, "uid": UID,
+ "node": NODE, "sealed": sealed, "keys": keys}), encoding="utf-8")
+ r = subprocess.run(["node", str(d / "h.cjs"), str(STATIC / "keyderive.js"),
+ str(STATIC / "auth-page.js"), str(d / "in.json")],
+ capture_output=True, text=True, timeout=60)
+ assert r.returncode == 0, r.stderr
+ return {"keys": keys, **json.loads(r.stdout)}
+
+
+@needs_node
+def test_a_recovery_copy_sealed_here_opens_in_a_browser(page):
+ assert page["opened"] == page["keys"]
+
+
+@needs_node
+def test_a_recovery_copy_sealed_in_a_browser_opens_here(page):
+ raw = recovery_bytes(page["js_mnemonic"])
+ assert raw == base64.b64decode(page["js_raw"])
+ assert recovery_mnemonic(raw) == page["js_mnemonic"]
+ assert open_recovery(page["js_sealed"], rkey=recovery_key(raw, USER), user_id=UID,
+ node_pk=NODE) == page["keys"]
+
+
+@needs_node
+def test_the_passphrase_estimate_is_the_pages(page):
+ assert [passphrase_bits(p) for p in PASSPHRASES] == page["bits"]
+
+
+def test_the_floor_is_twelve_characters_and_sixty_bits():
+ assert "12 characters" in passphrase_problem("Short1!")
+ assert "too easy" in passphrase_problem("aaaaaaaaaaaaaaaa")
+ assert "too easy" in passphrase_problem("password-password")
+ assert passphrase_problem("correct horse battery staple") is None
+
+
+def test_a_recovery_key_is_read_however_it_is_typed():
+ raw, mnemonic = generate_recovery()
+ assert re.fullmatch(r"(?:[A-Z2-7]{4} ){12}[A-Z2-7]{4}", mnemonic)
+ assert recovery_bytes(mnemonic.lower().replace(" ", "")) == raw
+ assert recovery_bytes(" " + mnemonic.replace(" ", "-") + "\n") == raw
+ with pytest.raises(ValueError):
+ recovery_bytes(mnemonic[:20])
+ with pytest.raises(ValueError):
+ recovery_bytes(mnemonic.replace(mnemonic[0], "1", 1))
+ assert recovery_key(raw, "alice") != recovery_key(raw, "bob")
+
+
+def test_the_proof_of_work_is_solved_as_the_hub_checks_it(monkeypatch):
+ captcha = pytest.importorskip("meshbay_hub.captcha")
+ from cryptography.hazmat.primitives import serialization
+ from meshbay_hub import auth
+ monkeypatch.setattr(auth, "_hub_sk_pem", Ed25519PrivateKey.generate().private_bytes(
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption()))
+ challenge = captcha.issue(5000)
+ token = solve_captcha(challenge)
+ assert token and captcha.verify(token)
+ assert solve_captcha({**challenge, "challenge": "00" * 32}) is None
diff --git a/packages/meshbay-cli/tests/test_cli_actions.py b/packages/meshbay-cli/tests/test_cli_actions.py
index bcbe0c4..006c183 100644
--- a/packages/meshbay-cli/tests/test_cli_actions.py
+++ b/packages/meshbay-cli/tests/test_cli_actions.py
@@ -205,6 +205,25 @@ def test_a_usage_error_is_exit_1_not_the_sign_in_code(capsys):
+def test_account_shows_the_account(hub, capsys):
+ assert main("--hub", "https://hub.test", "-u", USERNAME, "--password", PASSPHRASE,
+ "login") == 0
+ capsys.readouterr()
+ assert main("account") == 0
+ out = capsys.readouterr().out
+ assert f"Account: {USERNAME}" in out and "https://hub.test" in out
+ assert main("--json", "account", "show") == 0
+ assert json.loads(capsys.readouterr().out)["username"] == USERNAME
+
+
+def test_completion_prints_each_shells_script(capsys):
+ assert main("completion", "bash") == 0
+ assert "complete -F _meshbay_cli meshbay-cli" in capsys.readouterr().out
+ assert main("completion", "powershell") == 0
+ assert "Register-ArgumentCompleter -Native" in capsys.readouterr().out
+ assert main("completion", "fish") == 1
+
+
def test_help_prints_what_help_prints(capsys):
assert cli.main(["--help"]) == 0
whole = capsys.readouterr().out
@@ -472,6 +491,65 @@ def test_rm_deletes_only_what_this_account_may_and_asks_first(node, monkeypatch,
assert not node.files
+def test_rm_r_removes_a_folder_and_all_it_holds_from_the_operator_key(node, capsys):
+ from fake_hub import USER_ID
+ node.add("share/trip/day 1", "a.jpg", b"A" * 2000, uploader_id=USER_ID)
+ node.add("share/trip/day 2", "b.jpg", b"B" * 1000, uploader_id="someone-else")
+ node.add("share/trip", "notes.txt", b"n", uploader_id=USER_ID)
+ node.dirs |= {"share/trip/empty", "share/kept"}
+ kept = node.add("share/kept", "c.txt", b"c", uploader_id=USER_ID)
+
+ # Not the operator: refused whole, nothing deleted, not even its own files.
+ assert main("rm", "-r", "--yes", "Family/share/trip") == 8
+ assert "operator" in capsys.readouterr().err
+ assert len(node.files) == 4
+ # A root is never removed.
+ node.role = "operator"
+ assert main("rm", "-r", "--yes", "Family/share") == 8
+ assert "root" in capsys.readouterr().err
+
+ assert main("--json", "rm", "-r", "--yes", "Family/share/trip") == 0
+ out = json.loads(capsys.readouterr().out)
+ assert len(out["deleted"]) == 3 and out["failed"] == []
+ assert out["folders_removed"][-1] == "Family@alice-test/share/trip" # deepest first
+ assert {"Family@alice-test/share/trip/empty", "Family@alice-test/share/trip/day 1",
+ "Family@alice-test/share/trip/day 2"} <= set(out["folders_removed"])
+ assert set(node.files) == {kept["id"]}
+ assert not any(d == "share/trip" or d.startswith("share/trip/") for d in node.dirs)
+
+
+def test_rm_r_says_what_it_will_delete_before_it_asks(node, monkeypatch, capsys):
+ node.role = "operator"
+ for i in range(12):
+ node.add("share/big", f"f{i}.bin", b"x" * 1024)
+ monkeypatch.setattr("sys.stdin", _Terminal("n\n"))
+ assert main("rm", "-r", "Family/share/big") == 1
+ asked = capsys.readouterr().err
+ assert "Delete 12 files (12.0 KB) and 1 folder" in asked
+ assert "Family@alice-test/share/big/" in asked
+ assert len(node.files) == 12
+
+
+def test_mkdir_makes_folders_where_the_root_accepts_them(node, capsys):
+ assert main("mkdir", "Family/share/2026") == 0
+ assert "share/2026" in node.dirs
+ assert main("mkdir", "Family/share/2026") == 8 # already there
+ assert main("mkdir", "Family/share/a/b/c") == 3 # no parent
+ assert "--parents makes it too" in capsys.readouterr().err
+ assert main("--json", "mkdir", "--parents", "Family/share/a/b/c", "Family/share/2026") == 0
+ assert json.loads(capsys.readouterr().out)["made"] == [
+ "Family@alice-test/share/a", "Family@alice-test/share/a/b",
+ "Family@alice-test/share/a/b/c"]
+ node.add("share", "Notes.txt", b"n")
+ assert main("mkdir", "Family/share/notes.TXT") == 8 # a file, without case
+ assert "(a file)" in capsys.readouterr().err
+ assert main("mkdir", "Family/share/bad\x01name") == 8
+ assert main("mkdir", "Family/share") == 1 # a root is not made here
+ node.roots[0]["writable"] = False
+ assert main("mkdir", "Family/share/x") == 8
+ assert "read-only" in capsys.readouterr().err
+
+
def test_search_finds_across_groups_once_per_file(hub, monkeypatch, capsys):
from fake_node import FakeNode
from meshbay_cli.client.session import DeviceLinkPending
diff --git a/packages/meshbay-cli/tests/test_completion.py b/packages/meshbay-cli/tests/test_completion.py
new file mode 100644
index 0000000..1222dc2
--- /dev/null
+++ b/packages/meshbay-cli/tests/test_completion.py
@@ -0,0 +1,92 @@
+"""
+Tab completion's candidates: from the parser for actions, verbs and options,
+from the hub and the node for remote paths — never a question, never an error.
+"""
+
+import pytest
+from fake_hub import PASSPHRASE, USERNAME, FakeHub
+from fake_node import FakeNode
+from meshbay_cli import __main__ as cli
+from meshbay_cli import complete
+from meshbay_cli.client import hub as hub_module
+from meshbay_cli.client import session as session_module
+from meshbay_cli.commands import context
+
+
+def _c(*words, shell="bash"):
+ return complete.candidates(shell, len(words), ["meshbay-cli", *words])
+
+
+def test_actions_verbs_options_and_choices_come_from_the_parser():
+ assert "account" in _c("") and "mkdir" in _c("") and "completion" in _c("")
+ assert _c("ac") == ["account"]
+ assert _c("account", "") == ["show", "register", "verify", "passphrase", "reset",
+ "email", "sign-out-everywhere", "delete"]
+ assert _c("account", "pa") == ["passphrase"]
+ assert _c("devices", "a") == ["approve"]
+ assert _c("browser-access", "") == ["on", "off"]
+ assert _c("completion", "") == ["bash", "powershell"]
+ assert _c("help", "mk") == ["mkdir"]
+ assert "--parents" in _c("mkdir", "--") and "--yes" in _c("rm", "--")
+ assert _c("ls", "--sort", "") == ["name", "size", "date"]
+ # A value, then the slots go on: `get -o DIR PATH`.
+ assert _c("get", "-o", "") == [complete.FILES]
+ assert _c("--hub", "https://x", "ac") == ["account"]
+
+
+@pytest.mark.parametrize("typed, shell, plain", [
+ ("Some\\ Saga", "bash", "Some Saga"),
+ ("'Some Saga", "bash", "Some Saga"),
+ ('"Some Saga"', "bash", "Some Saga"),
+ ("'it''s'", "powershell", "it's"),
+ ("C:\\Users\\x", "powershell", "C:\\Users\\x"),
+])
+def test_quoting_is_taken_off_as_each_shell_wrote_it(typed, shell, plain):
+ assert complete.unquote(typed, shell) == plain
+
+
+def test_nothing_signed_in_gives_no_candidate_and_no_error(capsys):
+ assert cli.main(["__complete", "bash", "2", "meshbay-cli", "ls", "Fam"]) == 0
+ out = capsys.readouterr()
+ assert out.out == "" and out.err == ""
+ assert cli.main(["__complete", "garbage"]) == 0
+
+
+@pytest.fixture
+def world(monkeypatch):
+ hub, node = FakeHub(), FakeNode()
+ node.add("share/2024/été", "plage.jpg", b"x")
+ node.add("share", "notes.txt", b"n")
+ monkeypatch.setattr(context, "Hub", lambda url: hub)
+ monkeypatch.setattr(hub_module, "Hub", lambda url: hub)
+
+ async def group_nodes(token, gid):
+ return [{"node_id": "node-1", "pk_node": "pk"}]
+ hub.group_nodes = group_nodes
+ opened = []
+
+ async def connect(account, gid, node_row, join_code=None, *, rewrap=None):
+ assert rewrap is not None, "completion must never make keys"
+ opened.append(gid)
+ return node.session()
+ monkeypatch.setattr(session_module, "connect", connect)
+ assert cli.main(["--hub", hub.url, "-u", USERNAME, "--password", PASSPHRASE,
+ "login"]) == 0
+ return opened
+
+
+def test_remote_paths_from_the_hub_and_the_node(world, capsys):
+ capsys.readouterr()
+ assert _c("ls", "fa") == ["Family@alice-test/"]
+ assert _c("ls", "Family@alice-test/") == ["Family@alice-test/share/"]
+ assert _c("get", "Family@alice-test/share/") == [
+ "Family@alice-test/share/2024/", "Family@alice-test/share/notes.txt"]
+ assert _c("get", "Family@alice-test/share/2024/é") == [
+ "Family@alice-test/share/2024/été/"]
+ assert _c("mkdir", "Family@alice-test/share/") == ["Family@alice-test/share/2024/"]
+ assert _c("chat", "F") == ["Family@alice-test"]
+ assert _c("put", "x", "Family@alice-test/sh") == [complete.FILES,
+ "Family@alice-test/share/"]
+ assert _c("ls", "Nope/") == []
+ # The node was asked once; the next Tabs read what it said.
+ assert world == ["g1"]
diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py
index c385bb6..6b7869b 100644
--- a/packages/meshbay-cli/tests/test_e2e_local.py
+++ b/packages/meshbay-cli/tests/test_e2e_local.py
@@ -215,9 +215,12 @@ class App:
def __init__(self, hub_url: str, group_id: str):
self.hub_url, self.group_id = hub_url, group_id
self.ident = None
+ self.passphrase = PASSPHRASE
- async def session(self, code: str = ""):
- """Signed in, handshake done, joined: (channel, close, node_pk, nonce_s, user_id)."""
+ async def session(self, code: str = "", *, fetch: bool = False):
+ """Signed in, handshake done, joined: (channel, close, node_pk, nonce_s, user_id).
+ `fetch`: the node's copy of the account's keys, asked for in the handshake
+ window as a browser asks, is left in `self.fetched`."""
import os as _os
from aiortc import (
@@ -238,7 +241,7 @@ class App:
from meshbay_common.join import join_transcript
hub = Hub(self.hub_url)
- account = Account(hub, USERNAME, lambda: PASSPHRASE)
+ account = Account(hub, USERNAME, lambda: self.passphrase)
await _signed_in(account)
token = await account.token()
node = (await hub.group_nodes(token, self.group_id))[0]
@@ -261,6 +264,9 @@ class App:
"group_id": self.group_id, "nonce": b64(nonce_c)},
"handshake_challenge")
nonce_s, node_pk = unb64(chal["nonce"]), chal["node_pk"]
+ if fetch:
+ self.fetched = await ch.request({"type": "keypair_bundle_fetch",
+ "v": MNP_VERSION}, "keypair_bundle_resp")
if self.ident is None:
self.ident = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate())
ident, ts = self.ident, int(time.time())
@@ -304,6 +310,32 @@ class App:
assert stored.get("detail") == "keypair_bundle_stored", stored
await close()
+ async def leave_bundle(self, recovery: bytes | None = None) -> None:
+ """Browser access: this device's keys left on the node under `M`, and a
+ recovery copy beside it when the recovery key is held."""
+ from meshbay_cli.client.keys import seal_bundle, seal_recovery
+ from meshbay_common import MNP_VERSION
+ ch, close, node_pk, _, account = await self.session()
+ keys = self.ident.to_pkcs8()
+ msg = {"type": "keypair_bundle_store", "v": MNP_VERSION,
+ "bundle_enc": seal_bundle(keys, master=account.master, pepper_version=1,
+ user_id=account.user_id, node_pk=node_pk)}
+ if recovery is not None:
+ msg["bundle_enc_recovery"] = seal_recovery(keys, rkey=recovery,
+ user_id=account.user_id,
+ node_pk=node_pk)
+ assert (await ch.request(msg, "ack")).get("detail") == "keypair_bundle_stored"
+ await close()
+
+ async def held(self) -> dict:
+ """What the node keeps of the account's keys, as a browser fetches it."""
+ ch, close, node_pk, _, account = await self.session(fetch=True)
+ found = self.fetched
+ out = {"node_pk": node_pk, "user_id": account.user_id, "master": account.master,
+ **{k: found.get(k) for k in ("bundle_enc", "bundle_enc_recovery")}}
+ await close()
+ return out
+
async def withdraw_bundle(self) -> None:
"""Browser access turned off: the application withdraws the node's copy."""
from meshbay_common import MNP_VERSION
@@ -359,10 +391,11 @@ class App:
def cli(world: dict, *args: str, stdin: str | None = None,
- home: str = "cli") -> subprocess.CompletedProcess:
+ home: str = "cli", env_extra: dict | None = None) -> subprocess.CompletedProcess:
env = {**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / home),
"PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring"}
env.pop("MESHBAY_PASSWORD", None)
+ env.update(env_extra or {})
exe = os.environ.get("MESHBAY_CLI_E2E_BIN") or str(BIN / "meshbay-cli")
return subprocess.run([exe, *args], env=env, input=stdin,
capture_output=True, text=True, timeout=180)
@@ -1250,3 +1283,224 @@ def test_stream_a_film_to_real_players(world, tmp_path):
assert r.returncode == 0, r.stderr + log.read_text()
assert "The player was closed" in r.stderr
assert time.monotonic() - started > 10, "VLC played the film, in real time"
+
+
+def test_mkdir_and_rm_r_against_the_real_node(world, tmp_path):
+ """mkdir --parents, a file put in the new folder, then rm -r of it all from
+ the operator's key — paired as an operator pairs a browser."""
+ group = _handle()
+ r = cli(world, "rm", "-r", "--yes", f"{group}/share/2024", home="cli-chat")
+ assert r.returncode == 8 and "operator" in r.stderr, r.stderr # not yet
+
+ code = re.search(r"PAIRING CODE\s+(\S+)", world["run_node"]("operator", "pair")).group(1)
+ r = cli(world, "join", group, "--code", code, home="cli-chat")
+ assert r.returncode == 0, r.stderr
+
+ r = cli(world, "mkdir", "--parents", f"{group}/share/Album/Disc 1",
+ home="cli-chat")
+ assert r.returncode == 0, r.stderr
+ assert (world["share"] / "Album" / "Disc 1").is_dir()
+ (tmp_path / "track notes.txt").write_text("liner notes\n")
+ r = cli(world, "put", str(tmp_path / "track notes.txt"), f"{group}/share/Album/Disc 1",
+ home="cli-chat")
+ assert r.returncode == 0, r.stderr
+ assert (world["share"] / "Album" / "Disc 1" / "track notes.txt").is_file()
+ r = cli(world, "mkdir", f"{group}/share/Album/Empty", home="cli-chat")
+ assert r.returncode == 0, r.stderr
+ deadline = time.monotonic() + 30
+ while "track notes.txt" not in (r := cli(world, "ls", "-R", f"{group}/share/Album",
+ home="cli-chat")).stdout:
+ assert time.monotonic() < deadline, r.stdout # the node indexes it first
+ time.sleep(1)
+
+ r = cli(world, "--json", "rm", "-r", "--yes", f"{group}/share/Album", home="cli-chat")
+ assert r.returncode == 0, r.stderr
+ out = json.loads(r.stdout)
+ assert len(out["deleted"]) == 1 and len(out["folders_removed"]) == 3, out
+ assert not (world["share"] / "Album").exists()
+
+
+def test_tab_completes_remote_paths_from_the_real_node(world):
+ """The candidates come from the hub and the node, for the machine signed in;
+ bash's own completion function, and PowerShell's when pwsh is installed."""
+ group = _handle()
+
+ def complete(*words: str) -> list[str]:
+ r = cli(world, "__complete", "bash", str(len(words)), "meshbay-cli", *words,
+ home="cli-chat")
+ assert r.returncode == 0 and not r.stderr, r.stderr
+ return r.stdout.splitlines()
+
+ assert complete("ls", group[:4]) == [group + "/"]
+ assert complete("ls", f"{group}/sh") == [f"{group}/share/"]
+ assert complete("get", f"{group}/share/20") == [f"{group}/share/2024/"]
+ inside = complete("get", f"{group}/share/2024/")
+ assert f"{group}/share/2024/été à la mer/" in inside
+ assert f"{group}/share/2024/A_Very_Long_Name_Without_Any_Spaces_In_It_1992-2000.flac" \
+ in inside
+ folders = complete("mkdir", f"{group}/share/2024/") # a destination: folders only
+ assert f"{group}/share/2024/été à la mer/" in folders
+ assert all(f.endswith("/") for f in folders), folders
+ assert complete("chat", group[:4]) == [group]
+ assert complete("put", "nothing-here", f"{group}/sh")[0] == "__files__"
+ assert complete("ls", "No such group/") == [] # nothing, quietly
+
+ # bash itself: the function the script installs, on a word typed with its
+ # spaces escaped, as bash hands it over.
+ script = cli(world, "completion", "bash").stdout
+ escaped = group[:10].replace(" ", "\\ ")
+ bash = subprocess.run(
+ ["bash", "-c", script + "\nCOMP_WORDS=(meshbay-cli ls " + escaped + ")\n"
+ "COMP_CWORD=2\n_meshbay_cli\nprintf '%s\\n' \"${COMPREPLY[@]}\"\n"],
+ env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"),
+ "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring",
+ "PATH": f"{BIN}:{os.environ['PATH']}"},
+ capture_output=True, text=True, timeout=60)
+ assert bash.stdout.splitlines() == [group + "/"], (bash.stdout, bash.stderr)
+
+ pwsh = shutil.which("pwsh")
+ if pwsh:
+ ps = cli(world, "completion", "powershell").stdout
+ line = f"meshbay-cli ls '{group}/sh"
+ r = subprocess.run(
+ [pwsh, "-NoProfile", "-Command",
+ ps + f"\n$r = TabExpansion2 -inputScript \"{line}\" -cursorColumn {len(line)}"
+ "\n$r.CompletionMatches | ForEach-Object { $_.CompletionText }"],
+ env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"),
+ "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring",
+ "PATH": f"{BIN}:{os.environ['PATH']}"},
+ capture_output=True, text=True, timeout=120)
+ assert r.stdout.splitlines() == [f"'{group}/share/'"], (r.stdout, r.stderr)
+
+
+def _cli(world: dict, *args: str, new: str | None = None, **kw):
+ """`cli`, on this world's hub even from a machine that never signed in."""
+ extra = {"MESHBAY_HUB": world["hub_url"]}
+ if new is not None:
+ extra["MESHBAY_NEW_PASSWORD"] = new
+ return cli(world, *args, env_extra=extra, **kw)
+
+
+def _code(world: dict, purpose: str) -> str:
+ """The code the hub would have mailed: this throwaway hub mails nothing."""
+ with sqlite3.connect(world["db"]) as conn:
+ row = conn.execute("SELECT code FROM email_verifications WHERE purpose = ? "
+ "AND verified_at IS NULL ORDER BY created_at DESC LIMIT 1",
+ (purpose,)).fetchone()
+ assert row, f"no {purpose} code"
+ return row[0]
+
+
+def test_an_account_registered_verified_changed_and_erased_from_the_cli(world):
+ """register → verify → sign in → show → new address → sign out everywhere →
+ delete, each against the real hub; the e-mailed codes read from its database."""
+ name, email, new_email = "carol-e2e-cli", "carol@example.invalid", "carol2@example.invalid"
+ phrase = "carol's own long passphrase, 2026"
+ r = _cli(world, "account", "register", "-u", name, "--email", email,
+ "--password-file", "-", stdin=phrase + "\n", home="cli-carol")
+ assert r.returncode == 0, r.stderr
+ mnemonic = re.search(r"^ {4}((?:[A-Z2-7]{4} ){12}[A-Z2-7]{4})$", r.stdout, re.M)
+ assert mnemonic, r.stdout
+ assert "meshbay-cli account verify CODE" in r.stderr
+
+ # Not usable before the address is verified.
+ r = _cli(world, "login", "-u", name, "--password-file", "-", stdin=phrase + "\n",
+ home="cli-carol")
+ assert r.returncode == 2 and "not verified" in r.stderr, r.stderr
+ r = _cli(world, "account", "verify", _code(world, "registration"), "--email", email,
+ home="cli-carol")
+ assert r.returncode == 0 and "Verified" in r.stdout, r.stderr
+ r = _cli(world, "login", "-u", name, "--password-file", "-", stdin=phrase + "\n",
+ home="cli-carol")
+ assert r.returncode == 0, r.stderr
+
+ r = _cli(world, "--json", "account", home="cli-carol")
+ assert json.loads(r.stdout)["email"] == email, r.stdout
+ r = _cli(world, "account", "email", new_email, "--password-file", "-",
+ stdin=phrase + "\n", home="cli-carol")
+ assert r.returncode == 0 and "--code CODE" in r.stdout, (r.stdout, r.stderr)
+ r = _cli(world, "account", "email", "--code", _code(world, "email_change"),
+ home="cli-carol")
+ assert r.returncode == 0, r.stderr
+ r = _cli(world, "--json", "account", home="cli-carol")
+ assert json.loads(r.stdout)["email"] == new_email, r.stdout
+
+ r = _cli(world, "--json", "account", "sign-out-everywhere", home="cli-carol")
+ assert r.returncode == 0 and json.loads(r.stdout)["revoked"] >= 1, r.stderr
+ # This machine signs back in with its device key, without the passphrase.
+ r = _cli(world, "--json", "account", home="cli-carol")
+ assert r.returncode == 0, r.stderr
+
+ r = _cli(world, "account", "delete", home="cli-carol")
+ assert r.returncode == 1 and "--yes" in r.stderr, r.stderr # never unasked
+ r = _cli(world, "account", "delete", "--yes", "--password-file", "-",
+ stdin=phrase + "\n", home="cli-carol")
+ assert r.returncode == 0 and "erased" in r.stdout, r.stderr
+ r = _cli(world, "login", "-u", name, "--no-remember", "--password-file", "-",
+ stdin=phrase + "\n", home="cli-carol-2")
+ assert r.returncode == 2, r.stderr
+
+
+def test_a_new_passphrase_carries_the_browsers_keys_and_a_reset_restores_them(world):
+ """
+ The browsers' copy of the account's keys on the node — another device's
+ keys than this machine's — sealed again under the new passphrase, the same
+ keys; then, the passphrase forgotten, a machine that keeps nothing restores
+ them from the recovery copy. Last: it changes the account's passphrase.
+ """
+ from meshbay_cli.client.keys import (
+ generate_recovery,
+ open_recovery,
+ opens_under,
+ recovery_key,
+ )
+ app = world["app"]
+ raw, mnemonic = generate_recovery()
+ rkey = recovery_key(raw, USERNAME)
+ asyncio.run(app.leave_bundle(recovery=rkey))
+ browsers = app.ident.to_pkcs8()
+
+ new = "a brand new passphrase for alice, 2026"
+ r = _cli(world, "chat", "-1", _handle(), home="cli-chat")
+ assert r.returncode == 0, "the chat tests above did not leave this machine linked"
+ r = _cli(world, "account", "passphrase", "--password-file", "-",
+ stdin=PASSPHRASE + "\n", home="cli-chat", new=new)
+ assert r.returncode == 0, r.stderr
+ assert "sealed again on 1 node" in r.stdout, r.stdout
+
+ app.passphrase = new
+ held = asyncio.run(app.held())
+ keys = opens_under(held["bundle_enc"], held["master"], user_id=held["user_id"],
+ node_pk=held["node_pk"])
+ assert keys == browsers, "the browsers' keys were replaced, not carried"
+ # The recovery copy, left as it was, still opens.
+ assert open_recovery(held["bundle_enc_recovery"], rkey=rkey, user_id=held["user_id"],
+ node_pk=held["node_pk"]) == browsers
+ r = _cli(world, "login", "--no-remember", "-u", USERNAME, "--password-file", "-",
+ stdin=PASSPHRASE + "\n", home="cli-old")
+ assert r.returncode == 2, "the old passphrase still signs in"
+ # This machine goes on with the new one, without being asked for it.
+ r = _cli(world, "ls", _handle(), home="cli-chat")
+ assert r.returncode == 0, r.stderr
+
+ # Forgotten: a code, then a machine that keeps nothing and the recovery key.
+ reset = "the passphrase after the reset, 2026"
+ r = _cli(world, "account", "reset", "-u", USERNAME, "--email", "alice@example.invalid",
+ home="cli-reset")
+ assert r.returncode == 0 and "--code CODE" in r.stderr, r.stderr
+ key_file = world["root"] / "recovery.txt"
+ key_file.write_text(mnemonic + "\n")
+ r = _cli(world, "account", "reset", "-u", USERNAME, "--code",
+ _code(world, "password_reset"), "--recovery-key-file", str(key_file),
+ "--password-file", "-", stdin=reset + "\n", home="cli-reset")
+ assert r.returncode == 0, r.stderr
+ assert "sealed again on 1 node" in r.stdout, (r.stdout, r.stderr)
+
+ app.passphrase = reset
+ held = asyncio.run(app.held())
+ assert opens_under(held["bundle_enc"], held["master"], user_id=held["user_id"],
+ node_pk=held["node_pk"]) == browsers
+ assert open_recovery(held["bundle_enc_recovery"], rkey=rkey, user_id=held["user_id"],
+ node_pk=held["node_pk"]) == browsers
+ r = _cli(world, "ls", _handle(), home="cli-reset")
+ assert r.returncode == 0, r.stderr