From 988a0eecd522431980174910db28e45a63ff2710 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 12:32:24 +0200 Subject: feat(cli): account, rm -r and mkdir, Tab completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit account shows the account and does what the sign-up form and the Profile page do: register (the passphrase floor and the hub's proof of work checked here, the recovery key printed once), verify, change the passphrase, reset a forgotten one, change the e-mail address, sign out everywhere, delete. A new passphrase carries the copy of the account's keys each node keeps for browsers first — opened under the old M, the same keys sealed under the new one — and only then tells the hub; a refusal sets the copies back. A reset restores them from the recovery copy. The connections that do it never make keys nor ask for a device approval. This machine keeps the M it changed away from, so a node out of reach is carried at its next connection; a copy under an M it does not know is no longer rewritten, which locked the browsers out after a passphrase changed elsewhere. rm -r removes a folder and all it holds, from the node's operator key: the files, then the folders deepest first, one confirmation that counts them. mkdir makes folders, --parents the missing ones (not -p, the passphrase). completion bash|powershell prints the script that makes Tab complete actions, verbs, options and the paths of the groups this machine is signed in to, cached two minutes; the deb and rpm packages install the bash one. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-cli/src/meshbay_cli/__main__.py | 16 +- .../meshbay-cli/src/meshbay_cli/client/account.py | 9 + packages/meshbay-cli/src/meshbay_cli/client/hub.py | 60 +++ .../meshbay-cli/src/meshbay_cli/client/keys.py | 69 +++ .../src/meshbay_cli/client/passphrase.py | 64 +++ .../meshbay-cli/src/meshbay_cli/client/remove.py | 40 +- .../meshbay-cli/src/meshbay_cli/client/rewrap.py | 120 +++++ .../meshbay-cli/src/meshbay_cli/client/session.py | 144 +++++- .../src/meshbay_cli/commands/account.py | 488 +++++++++++++++++++++ .../src/meshbay_cli/commands/completion.py | 96 ++++ .../meshbay-cli/src/meshbay_cli/commands/mkdir.py | 108 +++++ .../meshbay-cli/src/meshbay_cli/commands/rm.py | 100 ++++- packages/meshbay-cli/src/meshbay_cli/complete.py | 319 ++++++++++++++ packages/meshbay-cli/src/meshbay_cli/manpage.py | 30 +- packages/meshbay-cli/tests/fake_node.py | 29 +- packages/meshbay-cli/tests/test_account_flows.py | 285 ++++++++++++ packages/meshbay-cli/tests/test_account_keys.py | 135 ++++++ packages/meshbay-cli/tests/test_cli_actions.py | 78 ++++ packages/meshbay-cli/tests/test_completion.py | 92 ++++ packages/meshbay-cli/tests/test_e2e_local.py | 262 ++++++++++- 20 files changed, 2474 insertions(+), 70 deletions(-) create mode 100644 packages/meshbay-cli/src/meshbay_cli/client/passphrase.py create mode 100644 packages/meshbay-cli/src/meshbay_cli/client/rewrap.py create mode 100644 packages/meshbay-cli/src/meshbay_cli/commands/account.py create mode 100644 packages/meshbay-cli/src/meshbay_cli/commands/completion.py create mode 100644 packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py create mode 100644 packages/meshbay-cli/src/meshbay_cli/complete.py create mode 100644 packages/meshbay-cli/tests/test_account_flows.py create mode 100644 packages/meshbay-cli/tests/test_account_keys.py create mode 100644 packages/meshbay-cli/tests/test_completion.py (limited to 'packages') 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 @@ -89,6 +89,13 @@ class Account: m = self.secrets.get("master") 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") @@ -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 + `` 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 -- cgit v1.2.3