diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 12:32:24 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 12:32:24 +0200 |
| commit | 988a0eecd522431980174910db28e45a63ff2710 (patch) | |
| tree | 80b16f8cfe907f084fac931fa35326c7f2892a9d /packages/meshbay-cli/src/meshbay_cli | |
| parent | e0a1036e0fee15b3c6c83f3e935d50f472a2e776 (diff) | |
| download | meshbay-988a0eecd522431980174910db28e45a63ff2710.tar.gz | |
feat(cli): account, rm -r and mkdir, Tab completion
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/__main__.py | 16 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/account.py | 9 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/hub.py | 60 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/keys.py | 69 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/passphrase.py | 64 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/remove.py | 40 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/rewrap.py | 120 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/session.py | 144 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/account.py | 488 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/completion.py | 96 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py | 108 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/rm.py | 100 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/complete.py | 319 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/manpage.py | 30 |
14 files changed, 1598 insertions, 65 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/__main__.py b/packages/meshbay-cli/src/meshbay_cli/__main__.py index f5b002a..f5cee22 100644 --- a/packages/meshbay-cli/src/meshbay_cli/__main__.py +++ b/packages/meshbay-cli/src/meshbay_cli/__main__.py @@ -12,8 +12,10 @@ import sys from . import __version__ from .commands import ( + account, browser_access, chat, + completion, decline, devices, get, @@ -25,6 +27,7 @@ from .commands import ( login, logout, ls, + mkdir, put, rm, search, @@ -34,10 +37,12 @@ from .commands import ( from .commands.context import Context from .errors import INTERRUPTED, USAGE, CliError -ACTIONS = {"login": login, "logout": logout, "groups": groups, "invitations": invitations, - "join": join, "decline": decline, "leave": leave, "ls": ls, "search": search, "get": get, - "stream": stream, "put": put, "rm": rm, "chat": chat, "devices": devices, - "trust": trust, "browser-access": browser_access, "help": help} +ACTIONS = {"login": login, "logout": logout, "account": account, "groups": groups, + "invitations": invitations, "join": join, "decline": decline, "leave": leave, + "ls": ls, "search": search, "get": get, + "stream": stream, "put": put, "mkdir": mkdir, "rm": rm, "chat": chat, "devices": devices, + "trust": trust, "browser-access": browser_access, + "completion": completion, "help": help} class _Parser(argparse.ArgumentParser): @@ -116,6 +121,9 @@ def loop_factory(): def main(argv: list[str] | None = None) -> int: argv = sys.argv[1:] if argv is None else argv + if argv[:1] == ["__complete"]: + from .complete import main as complete + return complete(argv[1:]) if not argv and os.environ.get("MESHBAY_CLI_SELFTEST") == "1": from .selftest import run return run() diff --git a/packages/meshbay-cli/src/meshbay_cli/client/account.py b/packages/meshbay-cli/src/meshbay_cli/client/account.py index 96483e9..fae1add 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/account.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/account.py @@ -90,6 +90,13 @@ class Account: return unb64(m) if m else None @property + def previous_masters(self) -> list[bytes]: + """The `M` of passphrases this machine changed away from: a copy a node + still holds under one is carried over at the next connection.""" + current = self.secrets.get("master") + return [unb64(m) for m in self.secrets.get("previous_masters", []) if m != current] + + @property def legacy(self) -> bytes | None: a = self.secrets.get("legacy") return unb64(a) if a else None @@ -175,6 +182,8 @@ class Account: "pepper_version": int(r.get("bundle_pepper_version") or 1), "legacy": b64(a), "identities": self.secrets.get("identities", {}) if same_account else {}, + "previous_masters": [m for m in self.secrets.get("previous_masters", []) + if m != b64(master)] if same_account else [], } if remember: device = (self.secrets.get("device_sk") if same_account else None) diff --git a/packages/meshbay-cli/src/meshbay_cli/client/hub.py b/packages/meshbay-cli/src/meshbay_cli/client/hub.py index 94d22da..5b301f8 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/hub.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/hub.py @@ -120,6 +120,66 @@ class Hub: async def me(self, token: str) -> dict: return await self._call("GET", "/v1/users/me", token=token) + # ── The account ────────────────────────────────────────────────────────── + + async def captcha(self) -> dict | None: + """A proof-of-work challenge, or None when this hub has the check off.""" + try: + return await self._call("GET", "/v1/users/captcha") + except HubError as e: + if e.status == 404: + return None + raise + + async def register(self, username: str, email: str, auth_key: str, + recovery_key: str | None, captcha_token: str | None) -> dict: + body = {"username": username, "email": email, "auth_key": auth_key} + if recovery_key: + body["recovery_key"] = recovery_key + if captcha_token: + body["captcha_token"] = captcha_token + return await self._call("POST", "/v1/users/register", json=body) + + async def verify_email(self, email: str, code: str) -> dict: + return await self._call("POST", "/v1/users/verify-email", + json={"email": email, "code": code}) + + async def bundle_pepper(self, token: str, auth_key: str) -> dict: + return await self._call("POST", "/v1/users/me/bundle-pepper", token=token, + json={"auth_key": auth_key}) + + async def change_passphrase(self, token: str, old_auth_key: str, + new_auth_key: str) -> dict: + return await self._call("POST", "/v1/users/password", token=token, + json={"old_auth_key": old_auth_key, + "new_auth_key": new_auth_key}) + + async def reset_request(self, username: str, email: str, + captcha_token: str | None) -> dict: + body = {"username": username, "email": email} + if captcha_token: + body["captcha_token"] = captcha_token + return await self._call("POST", "/v1/users/password/reset-request", json=body) + + async def reset_passphrase(self, username: str, code: str, new_auth_key: str) -> dict: + return await self._call("POST", "/v1/users/password/reset", json={ + "username": username, "code": code, "new_auth_key": new_auth_key}) + + async def change_email(self, token: str, email: str, auth_key: str) -> dict: + return await self._call("PATCH", "/v1/users/me", token=token, + json={"email": email, "auth_key": auth_key}) + + async def verify_email_change(self, token: str, code: str) -> dict: + return await self._call("POST", "/v1/users/verify-email-change", token=token, + json={"code": code}) + + async def revoke_sessions(self, token: str) -> dict: + return await self._call("POST", "/v1/users/me/sessions/revoke", token=token) + + async def delete_account(self, token: str, auth_key: str) -> dict: + return await self._call("DELETE", "/v1/users/me", token=token, + json={"auth_key": auth_key}) + # ── Groups and nodes ───────────────────────────────────────────────────── async def my_groups(self, token: str) -> list[dict]: diff --git a/packages/meshbay-cli/src/meshbay_cli/client/keys.py b/packages/meshbay-cli/src/meshbay_cli/client/keys.py index ecf44a5..2ac6f2c 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/keys.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/keys.py @@ -135,6 +135,75 @@ def bundle_is_current(bundle_b64: str, *, master: bytes, user_id: str, return False +def opens_under(bundle_b64: str, master: bytes, *, user_id: str, + node_pk: str) -> dict | None: + """The `{skEd, skX}` of an `MBK3` bundle sealed under `master`, else None.""" + try: + raw = unb64(bundle_b64) + if raw[:4] != BUNDLE_MAGIC: + return None + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + return json.loads(AESGCM(node_bundle_key(master, node_pk)).decrypt( + raw[5:17], raw[17:], aad)) + except Exception: + return None + + +# ── The recovery key (docs/MESHBAY_DESIGN.md §3.6) ─────────────────────────── +# +# 32 random bytes `R`, shown as 13 groups of four Base32 characters; the key +# that seals a recovery copy is HKDF(R, "meshbay:recovery:v1:" + username), +# used as it is — not per node — with the bundle's associated data and pepper +# version 0. As `keyderive.js` `generateRecoveryKey`/`deriveRecoveryKey`. + +_B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + + +def generate_recovery() -> tuple[bytes, str]: + """`R`, and the grouped mnemonic a person keeps.""" + raw = os.urandom(32) + return raw, recovery_mnemonic(raw) + + +def recovery_mnemonic(raw: bytes) -> str: + text = base64.b32encode(raw).decode().rstrip("=") + return " ".join(text[i:i + 4] for i in range(0, len(text), 4)) + + +def recovery_bytes(mnemonic: str) -> bytes: + """`R` from its mnemonic, however spaced or cased; ValueError if it is not one.""" + clean = "".join(c for c in mnemonic.upper() if c.isalnum()) + if any(c not in _B32 for c in clean): + raise ValueError("a recovery key is letters A to Z and digits 2 to 7") + raw = base64.b32decode(clean + "=" * (-len(clean) % 8)) if clean else b"" + if len(raw) < 32: + raise ValueError("this recovery key is too short") + return raw[:32] + + +def recovery_key(raw: bytes, username: str) -> bytes: + return hkdf(raw, f"meshbay:recovery:v1:{username}") + + +def seal_recovery(keys: dict, *, rkey: bytes, user_id: str, node_pk: str) -> str: + nonce = os.urandom(12) + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + plain = json.dumps({"skEd": keys["skEd"], "skX": keys["skX"]}).encode() + return b64(BUNDLE_MAGIC + bytes([0]) + nonce + AESGCM(rkey).encrypt(nonce, plain, aad)) + + +def open_recovery(bundle_b64: str, *, rkey: bytes, user_id: str, node_pk: str) -> dict: + try: + raw = unb64(bundle_b64) + if raw[:4] != BUNDLE_MAGIC: + raise ValueError + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + return json.loads(AESGCM(rkey).decrypt(raw[5:17], raw[17:], aad)) + except Exception as e: + raise BundleError("the node's recovery copy of your keys does not open with " + "this recovery key") from e + + @dataclass class Identity: """One account's keys on one node.""" diff --git a/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py b/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py new file mode 100644 index 0000000..071c708 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py @@ -0,0 +1,64 @@ +""" +A new passphrase's floor, and the hub's proof of work — both checked here. + +The hub never sees a passphrase (docs/MESHBAY_DESIGN.md §3.1), so the floor — +12 characters and about 60 estimated bits — can only be enforced by the client: +`auth-page.js` `passwordBits`, the same estimate, so a passphrase the page +refuses is refused here too. +""" + +import hashlib +import math +import re + +MIN_LENGTH = 12 +MIN_BITS = 60 +_WEAK = re.compile(r"(password|motdepasse|azerty|qwerty|123456|meshbay)", re.I) + + +def passphrase_bits(pw: str) -> int: + if not pw: + return 0 + pool = 0 + if re.search(r"[a-z]", pw): + pool += 26 + if re.search(r"[A-Z]", pw): + pool += 26 + if re.search(r"[0-9]", pw): + pool += 10 + if re.search(r"[^A-Za-z0-9]", pw): + pool += 32 + # JavaScript counts UTF-16 units; a character outside the BMP is two. + length = len(pw.encode("utf-16-le")) // 2 + bits = length * math.log2(pool or 1) + if len(set(pw)) < length / 2: + bits *= 0.6 + if re.fullmatch(r"[0-9]+", pw): + bits *= 0.5 + if _WEAK.search(pw): + bits *= 0.3 + # Math.round: halves go up. + return math.floor(bits + 0.5) + + +def passphrase_problem(pw: str) -> str | None: + """Why a new passphrase is refused, or None.""" + if len(pw.encode("utf-16-le")) // 2 < MIN_LENGTH: + return f"A passphrase has {MIN_LENGTH} characters at least." + if passphrase_bits(pw) < MIN_BITS: + return ("This passphrase is too easy to guess: make it longer, or mix in " + "other kinds of characters.") + return None + + +def solve_captcha(challenge: dict) -> str | None: + """ + The answer to `GET /v1/users/captcha`: the number below `max_number` whose + `<prefix><number>` hashes to `challenge` (`captcha.js` `solve`), as the token + the register and reset-request bodies carry. None if there is none. + """ + prefix, want = challenge["prefix"], bytes.fromhex(challenge["challenge"]) + for n in range(int(challenge["max_number"])): + if hashlib.sha256(f"{prefix}{n}".encode()).digest() == want: + return f"{prefix}{n}.{challenge['signature']}" + return None diff --git a/packages/meshbay-cli/src/meshbay_cli/client/remove.py b/packages/meshbay-cli/src/meshbay_cli/client/remove.py index 370cf95..ed9c65a 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/remove.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/remove.py @@ -1,15 +1,17 @@ """ -Deleting a file from a group (docs/MESHBAY_DESIGN.md §5.4). +Deleting a file, or an empty folder, from a group (docs/MESHBAY_DESIGN.md §5.4). The node decides, and on two grounds only: a key paired with it as operator, or a live device of the account that uploaded the file — any of them, so a file sent from a phone is deleted from the laptop. Being the group's owner on the hub -is neither. The request is answered with a challenge the client signs with its -key on the node; the node rebuilds what was signed itself. +is neither. A folder has no uploader: only the operator removes one, and only +an empty one — the node never deletes recursively. The request is answered with +a challenge the client signs with its key on the node; the node rebuilds what +was signed itself. """ from meshbay_common import MNP_VERSION -from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript +from meshbay_common.adminop import OP_DIR_DELETE, OP_FILE_DELETE, admin_transcript from ..errors import NODE, CliError from .keys import b64, unb64 @@ -24,22 +26,32 @@ def may_delete(session: GroupSession, entry: dict, user_id: str) -> bool: async def delete_file(session: GroupSession, file_id: str) -> None: """Delete one file. Raises `NodeRefusal` with the node's reason.""" - reply = await session.ch.request({"type": "file_delete", "v": MNP_VERSION, - "file_id": file_id}, - "admin_challenge", "file_delete_ack") - if reply["type"] == "file_delete_ack": + await _signed(session, {"type": "file_delete", "v": MNP_VERSION, "file_id": file_id}, + OP_FILE_DELETE, file_id, "file_delete_ack") + + +async def delete_dir(session: GroupSession, path: str) -> None: + """Remove one empty folder (root/folders), as the node's operator.""" + await _signed(session, {"type": "dir_delete", "v": MNP_VERSION, "dir": path}, + OP_DIR_DELETE, path, "dir_delete_ack") + + +async def _signed(session: GroupSession, msg: dict, op: str, subject: str, + ack: str) -> None: + reply = await session.ch.request(msg, "admin_challenge", ack) + if reply["type"] == ack: return # What is signed is checked against what was asked, never taken from the - # node: a challenge for another operation, file, group or node is refused. - if (reply.get("op") != OP_FILE_DELETE or reply.get("subject") != file_id + # node: a challenge for another operation, subject, group or node is refused. + if (reply.get("op") != op or reply.get("subject") != subject or reply.get("group_id") != session.group_id or reply.get("node_pk") != session.node_pk): raise CliError(NODE, "The node asked to sign something other than this deletion; " "nothing was signed.") - transcript = admin_transcript(op=OP_FILE_DELETE, node_pk_b64=session.node_pk, - group_id=session.group_id, subject=file_id, + transcript = admin_transcript(op=op, node_pk_b64=session.node_pk, + group_id=session.group_id, subject=subject, nonce=unb64(reply["nonce"]), ts=int(reply["ts"])) await session.ch.request({"type": "admin_response", "v": MNP_VERSION, - "op_id": reply["op_id"], "op": OP_FILE_DELETE, + "op_id": reply["op_id"], "op": op, "signature": b64(session.identity.sk_ed.sign(transcript))}, - "file_delete_ack") + ack) diff --git a/packages/meshbay-cli/src/meshbay_cli/client/rewrap.py b/packages/meshbay-cli/src/meshbay_cli/client/rewrap.py new file mode 100644 index 0000000..3ad74bb --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/rewrap.py @@ -0,0 +1,120 @@ +""" +Carrying the account's keys on every node across a new passphrase +(docs/MESHBAY_DESIGN.md §3.6). + +A node keeps, for browsers, a copy of the account's keys there sealed under +`M` — which the passphrase derives — and perhaps a second copy under the +recovery key. A new passphrase means a new `M`: each copy is opened (under the +old `M`, else the recovery copy under the recovery key) and sealed again, the +same keys, under the new one. Never this machine's keys in place of the +browsers': a copy is carried, not replaced — except after a reset, for an +account that leaves its keys for browsers from here, where the old copy no +longer opens and this machine's is the one it chose to leave. + +Once per node, whatever number of groups it serves: the copy is per account +and per node. A node that holds no keys of this account has nothing to carry; +a node out of reach keeps its copy as it is, and this machine carries it at +its next connection there (`session._settle_bundle`) when the old `M` is kept. +""" + +import logging +from collections.abc import Callable +from dataclasses import dataclass, field + +from meshbay_common import MNP_VERSION + +from ..errors import NODE, CliError +from .account import Account +from .keys import BundleError, seal_bundle, seal_recovery +from .paths import handle +from .session import NodeKeyChanged, NothingHere, Rewrap, connect, open_held + +log = logging.getLogger(__name__) + + +@dataclass +class Carried: + done: list[str] = field(default_factory=list) # node keys sealed again + nothing: list[str] = field(default_factory=list) # groups with nothing to carry + unreachable: list[str] = field(default_factory=list) # groups: no node online + failed: list[str] = field(default_factory=list) # "group: why" + + @property + def stragglers(self) -> list[str]: + return self.unreachable + self.failed + + +async def carry(account: Account, groups: list[dict], *, master: bytes, pepper_version: int, + recovery: bytes | None = None, own_keys: bool = False, + only: set[str] | None = None, + progress: Callable[[str], None] | None = None) -> Carried: + """ + Seal every reachable node's copy under `master`. `account` opens them: its + `M` is the one they are sealed under now. `recovery`: the recovery key, which + opens the recovery copy and gets a fresh one. `own_keys`: where no copy opens, + leave this machine's keys instead. `only`: these node keys and no others. + """ + out = Carried() + seen: set[str] = set() + for group in groups: + name = handle(group) + try: + nodes = await account.call(account.hub.group_nodes, group["id"]) + except CliError as e: + out.failed.append(f"{name}: {e.message}") + continue + nodes = [n for n in nodes if only is None or n["pk_node"] in only] + fresh = [n for n in nodes if n["pk_node"] not in seen] + if not nodes: + if only is None: + out.unreachable.append(name) + continue + if not fresh: + continue + for node in fresh: + if progress: + progress(name) + try: + what = await _carry_one(account, group, node, master, pepper_version, + recovery, own_keys) + except NodeKeyChanged as e: + out.failed.append(f"{name}: {e.message.splitlines()[0]}") + continue + except CliError as e: + out.failed.append(f"{name}: {e.message}") + continue + seen.add(node["pk_node"]) + if what == "done": + out.done.append(node["pk_node"]) + else: + out.nothing.append(name) + return out + + +async def _carry_one(account: Account, group: dict, node: dict, master: bytes, + pepper_version: int, recovery: bytes | None, own_keys: bool) -> str: + try: + session = await connect(account, group["id"], node, rewrap=Rewrap(recovery)) + except NothingHere: + return "nothing" + try: + held, node_pk = session.held, session.node_pk + if not held: + return "nothing" # this machine keeps its keys; none on the node + try: + keys = open_held(account, held, node_pk, recovery) + except BundleError as e: + if not own_keys: + raise CliError(NODE, f"{e}; left as it is") from e + keys = session.identity.to_pkcs8() + msg = {"type": "keypair_bundle_store", "v": MNP_VERSION, + "bundle_enc": seal_bundle(keys, master=master, pepper_version=pepper_version, + user_id=account.user_id, node_pk=node_pk)} + if recovery is not None: + msg["bundle_enc_recovery"] = seal_recovery(keys, rkey=recovery, + user_id=account.user_id, + node_pk=node_pk) + await session.ch.request(msg, "ack") + return "done" + finally: + await session.close() diff --git a/packages/meshbay-cli/src/meshbay_cli/client/session.py b/packages/meshbay-cli/src/meshbay_cli/client/session.py index 41bf0d6..d10acc8 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/session.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/session.py @@ -31,6 +31,7 @@ import logging import os import struct import time +from dataclasses import dataclass from datetime import UTC, datetime import msgpack @@ -64,11 +65,14 @@ from meshbay_common.join import join_transcript from ..errors import NODE, NODE_KEY, VERSION, CliError from .account import Account from .keys import ( + LEGACY_MAGIC, BundleError, Identity, b64, bundle_is_current, open_bundle, + open_recovery, + opens_under, seal_bundle, unb64, ) @@ -94,6 +98,25 @@ class NodeRefusal(CliError): self.refusal_code = code +class NothingHere(CliError): + """A node that holds no keys of this account: nothing to carry across.""" + + def __init__(self): + super().__init__(NODE, "this account has no keys on this node") + + +@dataclass +class Rewrap: + """ + A connection made only to carry this account's copy on the node across a + passphrase change or a reset: it never makes new keys, never asks for a + device approval, and reads the recovery copy with `recovery` (the recovery + key) where the passphrase copy does not open. + """ + + recovery: bytes | None = None + + class NodeKeyChanged(CliError): def __init__(self, node_id: str, pinned: str, now: str): super().__init__( @@ -214,6 +237,9 @@ class GroupSession: # paired with the node — the authority to delete anyone's file. Not the # hub's word on who owns the group (docs/MESHBAY_DESIGN.md §3.3). self.role = role + # What the node keeps of this account's keys, fetched by a Rewrap + # connection: `bundle_enc` and `bundle_enc_recovery`, or empty. + self.held: dict = {} async def index(self) -> dict: """`{version, entries, dirs, roots}`, opened under the group key.""" @@ -230,7 +256,8 @@ class GroupSession: async def connect(account: Account, group_id: str, node: dict, - join_code: str | None = None) -> GroupSession: + join_code: str | None = None, *, + rewrap: Rewrap | None = None) -> GroupSession: """ Open a session with `node` (a `/v1/groups/{id}/nodes` row) for `group_id`. `join_code`: an invitation's code, for a node that has not admitted this @@ -262,7 +289,7 @@ async def connect(account: Account, group_id: str, node: dict, "here).") from None binding = webrtc_binding(_dtls_fingerprint(offer_sdp), _dtls_fingerprint(answer_sdp)) return await _handshake(account, pc, ch, group_id, node_id, listed_pk, - mnp_token, binding, join_code) + mnp_token, binding, join_code, rewrap) except BaseException: with contextlib.suppress(Exception): await pc.close() @@ -271,7 +298,7 @@ async def connect(account: Account, group_id: str, node: dict, async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: str, listed_pk: str, mnp_token: str, binding: bytes, - join_code: str | None) -> GroupSession: + join_code: str | None, rewrap: Rewrap | None = None) -> GroupSession: nonce_c = os.urandom(32) challenge = await ch.request( {"type": "handshake", "v": MNP_VERSION, "v_min": MNP_MIN_SUPPORTED, @@ -293,8 +320,14 @@ async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: raise CliError(NODE, "The node's challenge is not signed by the key it " "announces.") from e - identity, source, bundle = await _identity(account, ch, node_pk) - if bundle is None and account.browser_access: + held: dict = {} + if rewrap is not None: + held = await _fetch_held(ch) + identity, source = await _rewrap_identity(account, node_pk, held, rewrap) + bundle = held.get("bundle_enc") or "" + else: + identity, source, bundle = await _identity(account, ch, node_pk) + if bundle is None and (account.browser_access or account.previous_masters): # Whether the node holds a copy, and a current one, decides what the # setting does after the proof. Asked here: the fetch is a pre-proof # message, and the window allows it. @@ -314,7 +347,7 @@ async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: if reason == "code_invalid": # Spent, cancelled or mistyped: it will not work on another try. await account.drop_join_code(group_id) - if reason == "unknown_device" and source != "bundle": + if reason == "unknown_device" and source != "bundle" and rewrap is None: await _link(account, ch, node_pk, nonce_s, identity, source) raise NodeRefusal(JOIN_REFUSALS.get(reason, reason or "the group key was not given"), reason) @@ -354,16 +387,59 @@ async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: await account.set_pin(node_id, node_pk) await _device_hello(account, ch, identity, node_pk, group_id, nonce_s) - await _settle_bundle(account, ch, identity, node_pk, bundle) - return GroupSession(pc, ch, group_id, node_id, node_pk, gek, config, - identity, nonce_s, str(joined.get("role") or "")) + if rewrap is None: + await _settle_bundle(account, ch, identity, node_pk, bundle) + session = GroupSession(pc, ch, group_id, node_id, node_pk, gek, config, + identity, nonce_s, str(joined.get("role") or "")) + session.held = held + return session -async def _fetch_bundle(ch: Channel) -> str: - """The node's copy of this account's keys, or "" when it keeps none.""" +async def _fetch_held(ch: Channel) -> dict: + """The node's copies of this account's keys: `bundle_enc`, sealed under `M`, + and `bundle_enc_recovery`, under the recovery key — {} when it keeps none.""" found = await ch.request({"type": "keypair_bundle_fetch", "v": MNP_VERSION}, "keypair_bundle_resp") - return str(found.get("bundle_enc") or "") if found.get("found") else "" + if not found.get("found"): + return {} + return {k: str(found[k]) for k in ("bundle_enc", "bundle_enc_recovery") if found.get(k)} + + +async def _fetch_bundle(ch: Channel) -> str: + """The node's copy of this account's keys, or "" when it keeps none.""" + return (await _fetch_held(ch)).get("bundle_enc", "") + + +def open_held(account: Account, held: dict, node_pk: str, + recovery: bytes | None) -> dict: + """The keys in the node's copy: under this session's `M` (or the legacy key), + else under the recovery key. BundleError when none opens it.""" + try: + return open_bundle(held.get("bundle_enc", ""), master=account.master, + legacy=account.legacy, user_id=account.user_id, + node_pk=node_pk) + except BundleError: + if recovery is None or not held.get("bundle_enc_recovery"): + raise + return open_recovery(held["bundle_enc_recovery"], rkey=recovery, + user_id=account.user_id, node_pk=node_pk) + + +async def _rewrap_identity(account: Account, node_pk: str, held: dict, + rewrap: Rewrap) -> tuple[Identity, str]: + """The keys to join with on a Rewrap connection: kept here, or the node's + copy opened — never new ones, and never a device request.""" + kept = account.identity(node_pk) + if kept is not None: + return kept, "kept" + if not held.get("bundle_enc") and not held.get("bundle_enc_recovery"): + raise NothingHere() + try: + identity = Identity.from_pkcs8(open_held(account, held, node_pk, rewrap.recovery)) + except BundleError as e: + raise CliError(NODE, str(e)) from e + await account.keep_identity(node_pk, identity) + return identity, "bundle" async def _identity(account: Account, ch: Channel, @@ -397,31 +473,51 @@ async def _identity(account: Account, ch: Channel, async def _settle_bundle(account: Account, ch: Channel, identity: Identity, node_pk: str, bundle: str | None) -> None: """ - What `browser-access` says to leave on this node (spec §2.12): `on`, a copy - sealed under the current `M` when the node holds none or an outdated one — - a current copy is left alone, whichever device sealed it, so two clients do - not take turns rewriting it; `off`, the node's copy withdrawn; unset, - nothing. A failure costs the browsers their copy, never this session. + What to leave on this node of the account's keys. + + A copy sealed under a passphrase this machine changed away from is sealed + again, same keys, under the current `M` — the node was out of reach when the + passphrase changed (docs/MESHBAY_DESIGN.md §3.6). Then what `browser-access` + says (spec §2.12): `on`, a copy of this machine's keys where the node holds + none, or only the older `MBK2`; `off`, the node's copy withdrawn; unset, + nothing. A copy in `MBK3` that opens under no key this machine knows is left + as it is: sealed under a passphrase set since, elsewhere, it is the one the + browsers open — rewriting it under the old one would lock them out. A + failure costs the browsers their copy, never this session. """ setting = account.browser_access - if not setting or bundle is None: + if bundle is None or not account.master: return try: + if bundle and setting != "off": + earlier = next((keys for m in account.previous_masters + if (keys := opens_under(bundle, m, user_id=account.user_id, + node_pk=node_pk)) is not None), None) + if earlier is not None: + await _store(account, ch, earlier, node_pk) + return if setting == "off" and bundle: await ch.request({"type": "keypair_bundle_delete", "v": MNP_VERSION}, "ack") - elif setting == "on" and account.master and not bundle_is_current( + elif setting == "on" and (not bundle or unb64(bundle)[:4] == LEGACY_MAGIC): + await _store(account, ch, identity.to_pkcs8(), node_pk) + elif setting == "on" and not bundle_is_current( bundle, master=account.master, user_id=account.user_id, node_pk=node_pk): - sealed = seal_bundle(identity.to_pkcs8(), master=account.master, - pepper_version=account.pepper_version, - user_id=account.user_id, node_pk=node_pk) - await ch.request({"type": "keypair_bundle_store", "v": MNP_VERSION, - "bundle_enc": sealed}, "ack") + log.warning("The node's copy of your keys is sealed under a passphrase this " + "machine does not know — changed elsewhere? It is left as it is; " + "run `meshbay-cli login` to sign in with the current one.") except CliError as e: log.warning("Browser access is %s here, but the node's copy of your keys was " "not %s: %s", setting, "withdrawn" if setting == "off" else "stored", e.message) +async def _store(account: Account, ch: Channel, keys: dict, node_pk: str) -> None: + sealed = seal_bundle(keys, master=account.master, pepper_version=account.pepper_version, + user_id=account.user_id, node_pk=node_pk) + await ch.request({"type": "keypair_bundle_store", "v": MNP_VERSION, + "bundle_enc": sealed}, "ack") + + # What a refused join means, for the person reading it. JOIN_REFUSALS = { "code_required": "this node has not admitted you yet; it needs the code of your " diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/account.py b/packages/meshbay-cli/src/meshbay_cli/commands/account.py new file mode 100644 index 0000000..2137995 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/account.py @@ -0,0 +1,488 @@ +""" +`account`: the account itself — see it, create one, verify its address, change +or reset the passphrase, change the address, end every session, erase it. + +What the application's sign-up form and Profile page do, with the same rules +(docs/MESHBAY_DESIGN.md §3.1, §3.6). The passphrase never leaves this machine: +the hub is shown the key derived from it, and the floor a new one must clear +is checked here, as the page checks it. A new passphrase carries the account's +keys on every node across first, and only then is the hub told — so a change +that cannot be carried leaves the account as it was. +""" + +import asyncio +import getpass +import json +import os +import sys +import time +from pathlib import Path + +from ..client.account import Account, NoPassphrase +from ..client.groups import my_groups +from ..client.hub import HubError +from ..client.keys import ( + auth_key, + b64, + bundle_master_key, + generate_recovery, + passphrase_half, + recovery_bytes, + recovery_key, + unb64, +) +from ..client.passphrase import passphrase_problem, solve_captcha +from ..client.rewrap import Carried, carry +from ..client.store import FILE_KEY_NOTICE, KEY_FILE +from ..errors import AUTH, NOT_FOUND, REFUSED, USAGE, CliError +from ..output import confirm, err, safe, sentence +from .context import Context + +HELP = ("your account: show it, register, verify, change or reset the passphrase, " + "change the e-mail address, sign out everywhere, delete it") +VERBS = ("show", "register", "verify", "passphrase", "reset", "email", + "sign-out-everywhere", "delete") +USERNAME_MIN = 8 # the hub's USERNAME_MIN_LEN +# How many earlier `M` this machine keeps, to carry copies on nodes that were +# out of reach when the passphrase changed. +KEEP_MASTERS = 5 + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("account", parents=[parent], help=HELP, description=sentence(HELP) + + ". With no verb, shows the account.") + p.add_argument("verb", nargs="?", default="show", choices=VERBS, + help="what to do; show when left out") + p.add_argument("arg", nargs="?", metavar="CODE|ADDRESS", + help="verify: the code e-mailed at registration; email: the new " + "address") + p.add_argument("--email", metavar="ADDRESS", + help="register, verify, reset: the account's e-mail address") + p.add_argument("--code", help="reset: the code e-mailed for it; email: the code sent " + "to the new address") + p.add_argument("--new-password-file", metavar="FILE", + help="passphrase: read the new passphrase from FILE's first line " + "(- for stdin); also MESHBAY_NEW_PASSWORD") + p.add_argument("--recovery-key-file", metavar="FILE", + help="reset: read the recovery key from FILE, to restore your keys " + "on the nodes") + p.add_argument("--email-recovery-key", action="store_true", + help="register: also send the recovery key with the verification " + "e-mail") + p.add_argument("--no-remember", action="store_true", + help="reset: sign in afterwards without storing the session") + p.add_argument("-y", "--yes", action="store_true", + help="delete without asking for confirmation") + + +async def run(ctx: Context) -> int: + a = ctx.args + await ctx.hub.check_version() + verb = a.verb + if verb == "register": + return await _register(ctx) + if verb == "verify": + return await _verify(ctx) + if verb == "reset": + return await _reset(ctx) + account = ctx.account() + try: + if verb == "show": + return await _show(ctx, account) + if verb == "passphrase": + return await _change_passphrase(ctx, account) + if verb == "email": + return await _email(ctx, account) + if verb == "sign-out-everywhere": + return await _sign_out_everywhere(ctx, account) + return await _delete(ctx, account) + finally: + await account.close() + + +# ── Reading what a person types ────────────────────────────────────────────── + +def _ask(prompt: str, given: str | None, what: str) -> str: + if given: + return given.strip() + if not sys.stdin.isatty(): + raise CliError(USAGE, f"Name the {what}: there is no terminal to ask on.") + value = input(prompt).strip() + if not value: + raise CliError(USAGE, f"No {what} given.") + return value + + +def _read_first_line(path: str) -> str: + try: + text = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8") + except OSError as e: + raise CliError(USAGE, f"Cannot read {path}: {e.strerror}") from e + return text.splitlines()[0] if text else "" + + +def _new_passphrase(given: str | None) -> str: + """A new passphrase: `given`, else typed twice at a terminal; held to the floor.""" + if given is None: + if not sys.stdin.isatty(): + raise NoPassphrase("A new passphrase is needed and there is no terminal to " + "ask on.") + given = getpass.getpass("New passphrase: ") + if problem := passphrase_problem(given): + raise CliError(USAGE, problem) + if getpass.getpass("The same again: ") != given: + raise CliError(USAGE, "The two passphrases differ; nothing was changed.") + if problem := passphrase_problem(given): + raise CliError(USAGE, problem) + return given + + +def _given_passphrase(ctx: Context) -> str | None: + """The global sources of a passphrase, without prompting.""" + a = ctx.args + if a.password is not None: + return a.password + if a.password_file: + return _read_first_line(a.password_file) + return os.environ.get("MESHBAY_PASSWORD") + + +async def _captcha(ctx: Context) -> str | None: + challenge = await ctx.hub.captcha() + if challenge is None: + return None + token = await asyncio.to_thread(solve_captcha, challenge) + if token is None: + raise CliError(REFUSED, "The hub's proof of work has no answer; try again.") + return token + + +def _hub_refusal(e: HubError, what: str) -> CliError: + if e.status == 429: + return CliError(REFUSED, f"{what}: too many attempts — {e.detail}") + if e.status in (401, 403): + return CliError(AUTH, f"{what}: {e.detail}") + if e.status == 404: + return CliError(NOT_FOUND, f"{what}: {e.detail}") + return CliError(REFUSED, f"{what}: {e.detail}") + + +def _say(ctx: Context, data: dict, text: str) -> None: + if ctx.args.json: + print(json.dumps(data, ensure_ascii=False)) + elif not ctx.args.quiet: + print(text) + + +# ── show ───────────────────────────────────────────────────────────────────── + +async def _show(ctx: Context, account: Account) -> int: + me = await account.call(account.hub.me) + if ctx.args.json: + print(json.dumps({"hub": ctx.hub.url, **{k: me.get(k) for k in ( + "user_id", "username", "email", "role", "status")}}, ensure_ascii=False)) + return 0 + print(f"Account: {safe(me.get('username', ''))}\n" + f"Hub: {ctx.hub.url}\n" + f"E-mail: {safe(me.get('email', ''))}\n" + f"Status: {safe(me.get('status', ''))}") + if me.get("role") and me["role"] != "user": + print(f"Role: {safe(me['role'])}") + return 0 + + +# ── register, verify ───────────────────────────────────────────────────────── + +async def _register(ctx: Context) -> int: + a = ctx.args + username = _ask("Username: ", ctx.username, "account name (--user)") + if len(username) < USERNAME_MIN or len(username) > 64 or \ + not username.replace("_", "").replace("-", "").replace(".", "").isalnum(): + raise CliError(USAGE, f"A username is {USERNAME_MIN} to 64 letters, digits, " + "-, _ or .") + email = _ask("E-mail address: ", a.email, "e-mail address (--email)") + passphrase = _new_passphrase(_given_passphrase(ctx)) + ak = await asyncio.to_thread(auth_key, username, passphrase) + raw, mnemonic = generate_recovery() + try: + try: + reg = await ctx.hub.register(username, email, ak, + mnemonic if a.email_recovery_key else None, + await _captcha(ctx)) + except HubError as e: + if e.detail not in ("captcha_failed", "captcha_required"): + raise + # An answer is spent once checked; a fresh one, once. + reg = await ctx.hub.register(username, email, ak, + mnemonic if a.email_recovery_key else None, + await _captcha(ctx)) + except HubError as e: + raise _hub_refusal(e, "Not registered") from e + + if ctx.args.json: + print(json.dumps({"user_id": reg.get("user_id"), "username": username, + "recovery_key": mnemonic, + "recovery_key_emailed": bool(a.email_recovery_key), + "email_verification_required": True})) + else: + print(f"Account {safe(username)} created on {ctx.hub.url}.\n\n" + "Your recovery key — shown this once, kept nowhere by MeshBay" + + (" (it is also in the verification e-mail)" if a.email_recovery_key + else "") + ":\n\n" + f" {mnemonic}\n\n" + "Keep it somewhere safe, apart from your passphrase. If you forget the " + "passphrase, it is what gives you your groups back.") + del raw + if not sys.stdin.isatty() or ctx.args.json: + err(f"A code was sent to {safe(email)}. Then: meshbay-cli account verify CODE " + f"--email {safe(email)}") + return 0 + code = input(f"\nThe code sent to {email} (Enter to do it later): ").strip() + if not code: + err(f"Later: meshbay-cli account verify CODE --email {safe(email)}") + return 0 + await _verify_code(ctx, email, code) + return 0 + + +async def _verify(ctx: Context) -> int: + a = ctx.args + code = _ask("The code: ", a.arg or a.code, "code") + email = _ask("E-mail address: ", a.email, "e-mail address (--email)") + await _verify_code(ctx, email, code) + return 0 + + +async def _verify_code(ctx: Context, email: str, code: str) -> None: + try: + await ctx.hub.verify_email(email, code) + except HubError as e: + raise _hub_refusal(e, "Not verified") from e + _say(ctx, {"verified": True, "email": email}, + "Verified. Sign in with: meshbay-cli login") + + +# ── passphrase: change a known one ─────────────────────────────────────────── + +async def _change_passphrase(ctx: Context, account: Account) -> int: + a = ctx.args + old = ctx.passphrase() + given = (_read_first_line(a.new_password_file) if a.new_password_file + else os.environ.get("MESHBAY_NEW_PASSWORD")) + new = _new_passphrase(given) + if new == old: + raise CliError(USAGE, "The new passphrase is the current one; nothing was changed.") + me = await account.call(account.hub.me) + if int(me.get("passphrase_locked_for") or 0) > 0: + raise CliError(AUTH, "Sign-in is locked for this account for " + f"{max(1, int(me['passphrase_locked_for']) // 60)} min; " + "nothing was changed.") + user, uid = account.username, me["user_id"] + old_ak = await asyncio.to_thread(auth_key, user, old) + try: + pep = await account.call(account.hub.bundle_pepper, old_ak) + except HubError as e: + if e.status == 403: + raise CliError(AUTH, "The current passphrase does not match; nothing was " + "changed.") from e + raise _hub_refusal(e, "Not changed") from e + pepper, version = unb64(pep["bundle_pepper"]), int(pep.get("bundle_pepper_version") or 1) + old_a = await asyncio.to_thread(passphrase_half, user, old) + new_a = await asyncio.to_thread(passphrase_half, user, new) + old_m, new_m = (bundle_master_key(old_a, pepper, uid), + bundle_master_key(new_a, pepper, uid)) + # What opens the copies as they are: the current passphrase's keys. + await account.update(lambda st, se: se.update( + master=b64(old_m), legacy=b64(old_a), pepper_version=version)) + + groups = await my_groups(account) + progress = None if a.quiet or a.json else (lambda g: err(f" {safe(g)}...")) + if progress: + err("Sealing your keys on each node under the new passphrase:") + carried = await carry(account, groups, master=new_m, pepper_version=version, + progress=progress) + new_ak = await asyncio.to_thread(auth_key, user, new) + try: + r = await account.call(account.hub.change_passphrase, old_ak, new_ak) + except (HubError, CliError) as e: + # The hub kept the old passphrase: the copies go back under it, opened + # with the new one they are sealed under now. + await account.update(lambda st, se: se.update(master=b64(new_m), legacy=b64(new_a))) + try: + back = await carry(account, groups, master=old_m, pepper_version=version, + only=set(carried.done)) + finally: + await account.update(lambda st, se: se.update(master=b64(old_m), + legacy=b64(old_a))) + detail = getattr(e, "detail", None) or getattr(e, "message", str(e)) + lost = len(carried.done) - len(back.done) + raise CliError(REFUSED, f"The hub refused the new passphrase ({detail}); " + "nothing was changed" + ( + f", but {lost} node(s) could not be set back and " + "hold your keys under the new one" if lost else "") + + ".") from e + + def commit(state: dict, secrets: dict) -> None: + kept = [m for m in secrets.get("previous_masters", []) if m != b64(old_m)] + secrets.update(master=b64(new_m), legacy=b64(new_a), pepper_version=version, + previous_masters=([b64(old_m)] + kept)[:KEEP_MASTERS], + access_token=r["access_token"], refresh_token=r["refresh_token"]) + secrets["access_exp"] = time.time() + int(r.get("expires_in", 0)) + await account.update(commit) + _report(ctx, carried, "Passphrase changed", changed=True) + return 0 + + +def _report(ctx: Context, carried: Carried, head: str, *, changed: bool) -> None: + if ctx.args.json: + print(json.dumps({"changed": changed, "nodes_sealed": len(carried.done), + "unreachable": carried.unreachable, "failed": carried.failed})) + return + print(f"{head}. Your keys were sealed again on {len(carried.done)} node" + f"{'s' if len(carried.done) != 1 else ''}.") + if carried.stragglers: + err("Not reached — " + ("this machine carries them over the next time it " + "connects there:" if changed else + "these groups may need their operator (`member unpin`, " + "then a fresh code):")) + for s in carried.stragglers: + err(f" {safe(s)}") + + +# ── reset: a forgotten passphrase ──────────────────────────────────────────── + +async def _reset(ctx: Context) -> int: + a = ctx.args + username = _ask("Username: ", ctx.username or ctx.stored_username, + "account name (--user)") + code = a.code + if not code: + email = _ask("The account's e-mail address: ", a.email, "e-mail address (--email)") + try: + await ctx.hub.reset_request(username, email, await _captcha(ctx)) + except HubError as e: + raise _hub_refusal(e, "No code sent") from e + err(f"If {safe(username)} and {safe(email)} are an account's, a code is on its " + "way there (valid one hour).") + if not sys.stdin.isatty(): + err(f"Then: meshbay-cli account reset -u {safe(username)} --code CODE") + return 0 + code = _ask("The code: ", None, "code") + passphrase = _new_passphrase(_given_passphrase(ctx)) + recovery = None + if a.recovery_key_file: + text = (sys.stdin.read() if a.recovery_key_file == "-" + else Path(a.recovery_key_file).read_text(encoding="utf-8")) + recovery = text.strip() + elif sys.stdin.isatty(): + recovery = input("Your recovery key (Enter if you have none): ").strip() + rkey = None + if recovery: + try: + rkey = recovery_key(recovery_bytes(recovery), username) + except ValueError as e: + raise CliError(USAGE, f"{sentence(str(e))}; nothing was changed.") from e + + new_ak = await asyncio.to_thread(auth_key, username, passphrase) + try: + await ctx.hub.reset_passphrase(username, code.strip(), new_ak) + except HubError as e: + raise _hub_refusal(e, "Not reset") from e + + # Signed in afresh, as `login` does: the reset ended every session and + # dropped every device key on the hub. + remember = not a.no_remember + account = Account(ctx.hub, username, lambda: passphrase) + stored = ctx.store.peek() + if stored.get("hub") == ctx.hub.url and stored.get("username") == username: + try: + ctx.store.load() + account.state, account.secrets = ctx.store.state, ctx.store.secrets + except CliError: + pass + try: + await account.sign_in(remember=remember) + if remember: + if await account.remember(ctx.store) == KEY_FILE: + err(FILE_KEY_NOTICE) + progress = None if a.quiet or a.json else (lambda g: err(f" {safe(g)}...")) + if progress: + err("Restoring your keys on each node:") + carried = await carry(account, await my_groups(account), master=account.master, + pepper_version=account.pepper_version, recovery=rkey, + own_keys=account.browser_access == "on", progress=progress) + finally: + await account.close() + _report(ctx, carried, "Passphrase reset, and signed in", changed=False) + if not rkey and not ctx.args.json: + err("Without the recovery key, groups whose keys this machine does not keep " + "need their operator (`member unpin`, then a fresh code).") + return 0 + + +# ── email, sign-out-everywhere, delete ─────────────────────────────────────── + +async def _email(ctx: Context, account: Account) -> int: + a = ctx.args + if a.code: + try: + r = await account.call(account.hub.verify_email_change, a.code.strip()) + except HubError as e: + raise _hub_refusal(e, "Not changed") from e + _say(ctx, {"email": r.get("email")}, f"E-mail address changed to " + f"{safe(r.get('email', ''))}.") + return 0 + address = _ask("New e-mail address: ", a.arg, "new address") + ak = await asyncio.to_thread(auth_key, account.username, ctx.passphrase()) + try: + await account.call(account.hub.change_email, address, ak) + except HubError as e: + raise _hub_refusal(e, "Not changed") from e + if ctx.args.json or not sys.stdin.isatty(): + _say(ctx, {"email_verification_required": True, "email": address}, + f"A code was sent to {safe(address)}. Then: meshbay-cli account email " + "--code CODE") + return 0 + code = input(f"The code sent to {address}: ").strip() + if not code: + err("Later: meshbay-cli account email --code CODE") + return 0 + try: + r = await account.call(account.hub.verify_email_change, code) + except HubError as e: + raise _hub_refusal(e, "Not changed") from e + print(f"E-mail address changed to {safe(r.get('email', ''))}.") + return 0 + + +async def _sign_out_everywhere(ctx: Context, account: Account) -> int: + r = await account.call(account.hub.revoke_sessions) + n = int(r.get("revoked") or 0) + _say(ctx, {"revoked": n}, + f"Signed out everywhere: {n} session{'s' if n != 1 else ''} ended. Devices " + "with a key of their own (this machine, if it is remembered) sign back in with " + "it; remove one from your devices to stop it.") + return 0 + + +async def _delete(ctx: Context, account: Account) -> int: + a = ctx.args + confirm(f"Erase the account {account.username} on {ctx.hub.url}, and every group " + "it owns? This cannot be undone.", a.yes) + ak = await asyncio.to_thread(auth_key, account.username, ctx.passphrase()) + try: + r = await account.call(account.hub.delete_account, ak) + except HubError as e: + if e.status == 403: + raise CliError(AUTH, "The passphrase does not match; nothing was erased.") from e + raise _hub_refusal(e, "Not erased") from e + if account.remembered: + with ctx.store.locked(): + ctx.store.erase() + gone = r.get("groups_deleted") or [] + _say(ctx, {"deleted": True, "groups_deleted": gone}, + f"Account {safe(account.username)} erased" + + (f", with {len(gone)} group{'s' if len(gone) != 1 else ''} it owned" + if gone else "") + ".") + return 0 + diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/completion.py b/packages/meshbay-cli/src/meshbay_cli/commands/completion.py new file mode 100644 index 0000000..831c012 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/completion.py @@ -0,0 +1,96 @@ +""" +`completion`: the script that makes Tab complete meshbay-cli in a shell. + +bash: eval "$(meshbay-cli completion bash)" (in ~/.bashrc) +PowerShell: meshbay-cli completion powershell | Out-String | Invoke-Expression + (in $PROFILE) + +The packages install the bash one where bash-completion finds it. Both scripts +ask `meshbay-cli __complete` for the candidates (see `complete.py`): actions, +verbs, options, and the remote paths of the groups this machine is signed in to. +""" + +from ..errors import USAGE, CliError +from ..output import sentence +from .context import Context + +HELP = "print the script that makes Tab complete meshbay-cli in bash or PowerShell" + +BASH = r'''# meshbay-cli completion for bash: eval "$(meshbay-cli completion bash)" +_meshbay_cli() { + local cur=${COMP_WORDS[COMP_CWORD]} line files=0 + local -a found=() + while IFS= read -r line; do + if [[ $line == __files__ ]]; then files=1; else found+=("$line"); fi + done < <(command meshbay-cli __complete bash "$COMP_CWORD" "${COMP_WORDS[@]}" 2>/dev/null) + COMPREPLY=("${found[@]}") + if (( files )); then + local f + while IFS= read -r f; do COMPREPLY+=("$f"); done < <(compgen -f -- "$cur") + fi + # Quote spaces and the like as for file names; no space after a folder. + compopt -o filenames 2>/dev/null + local c + for c in "${COMPREPLY[@]}"; do + if [[ $c == */ ]]; then compopt -o nospace 2>/dev/null; break; fi + done +} +complete -F _meshbay_cli meshbay-cli +''' + +POWERSHELL = r'''# meshbay-cli completion for PowerShell: +# meshbay-cli completion powershell | Out-String | Invoke-Expression +Register-ArgumentCompleter -Native -CommandName 'meshbay-cli', 'meshbay-cli.exe' -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $elements = @($commandAst.CommandElements) + $words = @($elements | ForEach-Object { $_.Extent.Text }) + $index = $words.Count + for ($i = 0; $i -lt $elements.Count; $i++) { + $e = $elements[$i].Extent + if ($cursorPosition -ge $e.StartOffset -and $cursorPosition -le $e.EndOffset) { + $index = $i + } + } + $found = @(& meshbay-cli __complete powershell $index @words 2>$null) + $quote = { + param($s) + if ($s -match '[\s''"`$&|;(){}@,#]') { "'" + ($s -replace "'", "''") + "'" } else { $s } + } + $item = [System.Management.Automation.CompletionResult] + $results = @() + foreach ($c in $found) { + if ($c -eq '__files__') { + $sep = [IO.Path]::DirectorySeparatorChar + $typed = $wordToComplete.Trim([char[]]"'`"") + $dir = Split-Path -Path $typed -Parent + Get-ChildItem -Path "$typed*" -ErrorAction SilentlyContinue | + ForEach-Object { + $p = if ($dir) { Join-Path $dir $_.Name } else { $_.Name } + if ($_.PSIsContainer) { $p += $sep } + $results += $item::new((& $quote $p), $p, 'ProviderItem', $p) + } + continue + } + $results += $item::new((& $quote $c), $c, 'ParameterValue', $c) + } + $results +} +''' + +SCRIPTS = {"bash": BASH, "powershell": POWERSHELL} + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("completion", parents=[parent], help=HELP, description=sentence(HELP) + + '. bash: eval "$(meshbay-cli completion bash)" in ~/.bashrc; ' + "PowerShell: meshbay-cli completion powershell | Out-String | " + "Invoke-Expression in $PROFILE.") + p.add_argument("shell", choices=tuple(SCRIPTS), help="the shell") + + +async def run(ctx: Context) -> int: + script = SCRIPTS.get(ctx.args.shell) + if script is None: + raise CliError(USAGE, "bash or powershell.") + print(script, end="") + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py b/packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py new file mode 100644 index 0000000..de8cdcb --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py @@ -0,0 +1,108 @@ +""" +`mkdir`: make folders in a group, as the Files tab's "New folder" does. + +Any member may, where the root accepts uploads: organising where files go is +not privileged on the node (removing a folder is — see `rm -r`). The parent +must exist, unless --parents makes the missing ones too; with it a folder +already there is not an error. Not -p, which is the passphrase everywhere in +this program. Names follow the node's rule for uploads, checked here +first so nothing is sent that it would refuse. +""" + +import json + +from ..client.groups import my_groups +from ..client.names import same_name, upload_name_problem +from ..client.paths import handle, parse, resolve_group +from ..client.pool import Pool +from ..client.transfers import TransferError, create_dir +from ..client.tree import Tree +from ..errors import NOT_FOUND, PARTIAL, REFUSED, USAGE, CliError +from ..output import err, safe, sentence +from .context import Context + +HELP = "make folders in a group" + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("mkdir", parents=[parent], help=HELP, description=sentence(HELP) + + ", where its root accepts uploads.") + p.add_argument("paths", nargs="+", metavar="PATH", + help="group[@owner]/root/.../new-folder") + p.add_argument("--parents", action="store_true", + help="make missing parent folders too; a folder already there is " + "not an error (not -p: that is --password)") + + +async def run(ctx: Context) -> int: + await ctx.hub.check_version() + account = ctx.account() + pool = Pool(account) + try: + return await _run(ctx, account, pool) + finally: + await pool.close() + await account.close() + + +async def _run(ctx: Context, account, pool: Pool) -> int: + a = ctx.args + groups = await my_groups(account) + plans = [] # (session, group, [(parent, name), ...]) + for text in a.paths: + remote = parse(text) + if remote is None or len(remote.parts) < 2: + raise CliError(USAGE, "Name the folder to make inside a root: " + "group[@owner]/root/folder") + group = resolve_group(groups, remote.group) + session = await pool.session(group) + tree = Tree.from_index(await session.index()) + shown = f"{handle(group)}/{remote.inner}" + root = tree.roots.get(remote.parts[0]) + if root is None: + raise CliError(NOT_FOUND, f"No root {safe(remote.parts[0])} in " + f"{safe(handle(group))}.") + if not root.get("available", True): + raise CliError(REFUSED, f"{safe(shown)}: its drive is disconnected from the node.") + if not root.get("writable"): + raise CliError(REFUSED, f"{safe(shown)}: the root is read-only — its operator " + "does not accept uploads there.") + steps = [] + for depth in range(2, len(remote.parts) + 1): + parent, name = "/".join(remote.parts[:depth - 1]), remote.parts[depth - 1] + if (why := upload_name_problem(name, is_dir=True)): + raise CliError(REFUSED, f"{safe(name)}: {why}") + path = f"{parent}/{name}" + if tree.is_dir(path): + continue + # Folders are told apart without case, as the node does. + clash = next((i for i in tree.children(parent) if same_name(i.name, name)), + None) if tree.is_dir(parent) else None + if clash is not None: + raise CliError(REFUSED, f"{safe(handle(group))}/{safe(clash.path)} is already " + "there" + ("" if clash.is_dir else " (a file)") + ".") + if depth < len(remote.parts) and not a.parents: + raise CliError(NOT_FOUND, f"No folder {safe(handle(group))}/{safe(path)} " + "(--parents makes it too).") + steps.append((parent, name)) + if not steps and not a.parents: + raise CliError(REFUSED, f"{safe(shown)} is already there.") + plans.append((session, group, steps)) + + made, failed = [], [] + for session, group, steps in plans: + for parent, name in steps: + try: + path = await create_dir(session, parent, name) + except TransferError as e: + failed.append(f"{handle(group)}/{parent}/{name}: {e.message}") + break # the folders below it cannot follow + made.append(f"{handle(group)}/{path}") + if a.json: + print(json.dumps({"made": made, "failed": failed}, ensure_ascii=False)) + elif not a.quiet: + for m in made: + err(f"Made {safe(m)}/") + for f in failed: + err(f"meshbay-cli: {safe(f)}") + return PARTIAL if failed else 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/rm.py b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py index 6f5e869..7f4a5c6 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/rm.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py @@ -1,12 +1,18 @@ """ -`rm`: delete files from groups. Asked first; `--yes` answers for a script. +`rm`: delete files from groups, and with -r folders and all they hold. Asked +first; `--yes` answers for a script. Paths and patterns are resolved against each group's index, as `get` resolves them, and the whole run is checked before anything is asked or sent: a file this account may not delete — not uploaded by it, and this machine's key not -the node's operator — refuses the run (exit 8), as a folder does. The node -checks again on every file; one it refuses is reported and the others go on -(exit 4). +the node's operator — refuses the run (exit 8), as a folder does without -r. + +With -r, a folder is everything below it: its files first, then its folders, +deepest first. The node removes only empty folders, and only for its operator's +key, so -r on a folder is the operator's, and the confirmation counts every +file before one is deleted. A group's root is never removed. The node checks +again on every file and folder; one it refuses is reported and the others go +on (exit 4) — a folder still holding a file it refused stays. """ import json @@ -14,13 +20,14 @@ import json from ..client.groups import my_groups from ..client.paths import handle, parse, resolve_group from ..client.pool import Pool -from ..client.remove import delete_file, may_delete +from ..client.remove import delete_dir, delete_file, may_delete from ..client.tree import Tree from ..errors import NODE, NOT_FOUND, PARTIAL, REFUSED, USAGE, CliError -from ..output import confirm, err, safe, sentence +from ..output import confirm, err, human_size, safe, sentence from .context import Context -HELP = "delete files you uploaded (or any, from the node's operator key)" +HELP = ("delete files you uploaded (or any, from the node's operator key); with -r, " + "folders too, from the operator's key") # How many names the confirmation shows before it counts the rest. SHOWN = 10 @@ -32,6 +39,8 @@ def add_parser(sub, parent) -> None: "quote them. Being the group's owner is not enough: the node " "lets its operator's key, and the uploader's devices, delete.") p.add_argument("paths", nargs="+", metavar="PATH", help="group[@owner]/root/.../file") + p.add_argument("-r", "-R", dest="recursive", action="store_true", + help="folders, with everything in them (the node's operator only)") p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation") @@ -51,7 +60,8 @@ async def _run(ctx: Context, account, pool: Pool) -> int: groups = await my_groups(account) trees: dict[str, Tree] = {} targets = [] # (session, entry, shown) - missing, folders, not_yours = [], [], [] + dirs = [] # (session, path, shown) + missing, folders, not_yours, roots, not_operator = [], [], [], [], [] for text in a.paths: remote = parse(text) if remote is None or not remote.parts: @@ -69,30 +79,65 @@ async def _run(ctx: Context, account, pool: Pool) -> int: for path in found: shown = f"{handle(group)}/{path}" entry = tree.file(path) - if entry is None: + if entry is not None: + if not may_delete(session, entry, account.user_id): + not_yours.append(shown) + elif all(e["id"] != entry["id"] for _, e, _ in targets): + targets.append((session, entry, shown)) + continue + if not a.recursive: folders.append(shown) - elif not may_delete(session, entry, account.user_id): - not_yours.append(shown) - elif all(e["id"] != entry["id"] for _, e, _ in targets): - targets.append((session, entry, shown)) + elif "/" not in path: + roots.append(shown) + elif session.role != "operator": + not_operator.append(shown) + elif not tree.root_available(path): + raise CliError(REFUSED, f"{safe(shown)} is on a drive the node does not see " + "now; nothing was deleted.") + else: + for folder, kids in tree.walk(path): + if all(d[1] != folder or d[0] is not session for d in dirs): + dirs.append((session, folder, f"{handle(group)}/{folder}")) + for kid in kids: + if not kid.is_dir and all(e["id"] != kid.entry["id"] + for _, e, _ in targets): + targets.append((session, kid.entry, + f"{handle(group)}/{kid.path}")) if missing: raise CliError(NOT_FOUND, "Nothing in your groups matches: " + ", ".join(safe(m) for m in missing)) if folders: - raise CliError(USAGE, "rm deletes files; these are folders: " - + ", ".join(safe(f) for f in folders)) + raise CliError(USAGE, "These are folders; -r deletes them with everything in " + "them: " + ", ".join(safe(f) for f in folders)) + if roots: + raise CliError(REFUSED, "A group's root is not deleted here — its operator removes " + "it from the node: " + ", ".join(safe(r) for r in roots)) + if not_operator: + raise CliError(REFUSED, "Not deleted — removing a folder is the node operator's, " + "and this machine's key is not:\n " + + "\n ".join(safe(n) for n in not_operator)) if not_yours: raise CliError(REFUSED, "Not deleted — you did not upload these, and this machine's " "key is not the node's operator:\n " + "\n ".join(safe(n) for n in not_yours)) names = [shown for _, _, shown in targets] - listed = "\n ".join(safe(n) for n in names[:SHOWN]) - more = f"\n ... and {len(names) - SHOWN} more" if len(names) > SHOWN else "" - confirm(f"Delete {len(names)} file{'s' if len(names) != 1 else ''} for every member " - f"of the group?\n {listed}{more}\n", a.yes) + # The deepest first: each one is empty by the time it is asked for. + dirs.sort(key=lambda d: d[1].count("/"), reverse=True) + tops = [shown for _, path, shown in dirs + if not any(path.startswith(other + "/") for _, other, _ in dirs)] + shown_names = tops + [n for n in names if not any(n.startswith(t + "/") for t in tops)] + listed = "\n ".join(safe(n) + ("/" if n in tops else "") for n in shown_names[:SHOWN]) + more = (f"\n ... and {len(shown_names) - SHOWN} more" + if len(shown_names) > SHOWN else "") + size = sum(int(e.get("size", 0)) for _, e, _ in targets) + what = f"{len(names)} file{'s' if len(names) != 1 else ''}" + if dirs: + what += (f" ({human_size(size)}) and {len(dirs)} folder" + f"{'s' if len(dirs) != 1 else ''}") + confirm(f"Delete {what} for every member of the group?\n {listed}{more}\n", a.yes) - deleted, failed = [], [] + deleted, removed, failed = [], [], [] for session, entry, shown in targets: try: await delete_file(session, entry["id"]) @@ -101,11 +146,22 @@ async def _run(ctx: Context, account, pool: Pool) -> int: if e.code not in (NODE, REFUSED): raise failed.append(f"{shown}: {e.message}") + for session, path, shown in dirs: + try: + await delete_dir(session, path) + removed.append(shown) + except CliError as e: + if e.code not in (NODE, REFUSED): + raise + failed.append(f"{shown}/: {e.message}") if a.json: - print(json.dumps({"deleted": deleted, "failed": failed}, ensure_ascii=False)) + print(json.dumps({"deleted": deleted, "folders_removed": removed, "failed": failed}, + ensure_ascii=False)) elif not a.quiet: n = len(deleted) - err(f"Deleted {n} file{'s' if n != 1 else ''}.") + err(f"Deleted {n} file{'s' if n != 1 else ''}" + + (f" and {len(removed)} folder{'s' if len(removed) != 1 else ''}" + if dirs else "") + ".") for f in failed: err(f"meshbay-cli: {safe(f)}") return PARTIAL if failed else 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/complete.py b/packages/meshbay-cli/src/meshbay_cli/complete.py new file mode 100644 index 0000000..ac590ce --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/complete.py @@ -0,0 +1,319 @@ +""" +Shell completion: what the word under the cursor may become. + +`meshbay-cli __complete SHELL CWORD WORD...` prints one candidate per line, for +the scripts `completion` prints. Actions, verbs and options come from the +parser, so they cannot drift from it. Remote paths — `group@owner/root/folder/` +— come from the hub (the groups) and the node (a group's folders and files), +for the account this machine remembers only: nothing is asked, no passphrase +and no device approval, and a node is opened the way that never makes keys +(`session.Rewrap`). What a group holds is cached for two minutes, so the next +Tab is instant. Anything that fails is no candidate, never an error on screen. + +`__files__` on a line of its own asks the shell for its own file completion +(a local path: `put`'s sources, `-o`). +""" + +import argparse +import asyncio +import contextlib +import json +import logging +import os +import sys +import time + +FILES = "__files__" +CACHE_TTL = 120 +DEADLINE = 8.0 +GLOBAL_VALUES = {"--hub", "-u", "--user", "-p", "--password", "--password-file"} +FILE_OPTIONS = {"--password-file", "-o", "--new-password-file", "--recovery-key-file"} + + +def unquote(word: str, shell: str) -> str: + """The word as typed, its quoting taken off: bash's backslashes and quotes, + PowerShell's quotes and doubled single quote.""" + if len(word) >= 1 and word[0] == "'": + body = word[1:-1] if len(word) > 1 and word.endswith("'") else word[1:] + return body.replace("''", "'") if shell == "powershell" else body + if len(word) >= 1 and word[0] == '"': + body = word[1:-1] if len(word) > 1 and word.endswith('"') else word[1:] + return body.replace('\\"', '"').replace('`"', '"') + if shell == "bash": + out, escaped = [], False + for c in word: + if escaped or c != "\\": + out.append(c) + escaped = False + else: + escaped = True + return "".join(out) + return word + + +def _subparsers(parser: argparse.ArgumentParser) -> dict[str, argparse.ArgumentParser]: + sub = next(a for a in parser._actions if isinstance(a, argparse._SubParsersAction)) + return dict(sub.choices) + + +def _options(parser: argparse.ArgumentParser) -> dict[str, argparse.Action]: + return {s: a for a in parser._actions for s in a.option_strings} + + +def plan(words: list[str], cword: int, parser: argparse.ArgumentParser): + """ + What kind of word is under the cursor: ("words", [candidates]), ("files",), + ("remote", action), ("groups",), ("invitations",), ("pins",), or ("none",). + `words[0]` is the program. + """ + from .__main__ import ACTIONS + subs = _subparsers(parser) + current = words[cword] if cword < len(words) else "" + action, positionals, i = None, [], 1 + top = _options(parser) + while i < cword: + w = words[i] + opts = _options(subs[action]) if action else top + if w.startswith("-") and w != "-": + a = opts.get(w.split("=", 1)[0]) + if a is not None and a.nargs != 0 and "=" not in w: + i += 1 # its value + elif action is None and w in ACTIONS: + action = w + elif action is not None: + positionals.append(w) + i += 1 + + prev = words[cword - 1] if cword - 1 >= 1 else "" + opts = _options(subs[action]) if action else top + taking = opts.get(prev) + if prev.startswith("-") and taking is not None and taking.nargs != 0: + if taking.choices: + return ("words", [str(c) for c in taking.choices]) + return ("files",) if prev in FILE_OPTIONS else ("none",) + + if current.startswith("-"): + names = sorted(opts) if action else sorted(top) + return ("words", [n for n in names if n.startswith("--") or len(n) == 2]) + if action is None: + return ("words", list(ACTIONS)) + + slots = [a for a in subs[action]._actions if not a.option_strings] + slot = _slot(slots, len(positionals), action) + if slot is None: + return ("none",) + if slot.choices: + return ("words", [str(c) for c in slot.choices]) + meta = slot.metavar or "" + if meta == "ACTION": + return ("words", list(ACTIONS)) + if meta == "SOURCE": + return ("files+remote", action) + if meta in ("GROUP",): + return ("groups",) + if meta in ("INVITATION", "INVITATION|LINK"): + return ("invitations",) + if action == "trust": + return ("pins",) + if meta in ("PATH", "DEST") or slot.dest == "path": + return ("remote", action) + return ("none",) + + +def _slot(slots: list[argparse.Action], n: int, action: str): + """The positional the n-th word fills. `put SOURCE... DEST`: every word is a + source or the destination, and either may be what is being typed.""" + if action == "put": + return slots[0] + for a in slots: + if a.nargs in ("+", "*"): + return a + if n == 0: + return a + n -= 1 + return None + + +# ── The account's groups, and what they hold ───────────────────────────────── + +def _cache_path(): + from .client.store import data_dir + return data_dir() / "completion-cache.json" + + +def _cache_read() -> dict: + try: + return json.loads(_cache_path().read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def _cache_write(cache: dict) -> None: + with contextlib.suppress(OSError): + path = _cache_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(cache), encoding="utf-8") + os.replace(tmp, path) + + +def _fresh(entry: dict | None) -> bool: + return bool(entry) and time.time() - entry.get("at", 0) < CACHE_TTL + + +async def _account(): + from .client.account import Account, no_passphrase + from .client.hub import Hub + from .client.store import Store + store = Store() + stored = store.peek() + if not stored.get("hub"): + return None + hub = Hub(os.environ.get("MESHBAY_HUB") or stored["hub"]) + try: + account = Account.open(hub, None, no_passphrase, store) + except Exception: + await hub.aclose() + return None + if not account.remembered: + await hub.aclose() + return None + return account + + +async def _groups(account, cache: dict) -> list[dict]: + key = f"groups:{account.hub.url}:{account.username}" + if _fresh(cache.get(key)): + return cache[key]["rows"] + from .client.groups import my_groups + rows = [{k: g.get(k) for k in ("id", "name", "owner_username")} + for g in await my_groups(account)] + cache[key] = {"at": time.time(), "rows": rows} + return rows + + +async def _tree(account, group: dict, cache: dict): + from .client.session import Rewrap, connect + from .client.tree import Tree + key = f"index:{group['id']}" + if not _fresh(cache.get(key)): + nodes = await account.call(account.hub.group_nodes, group["id"]) + nodes = sorted(nodes, key=lambda n: account.pin(n["node_id"]) is None) + index = None + for node in nodes: + try: + session = await connect(account, group["id"], node, rewrap=Rewrap()) + except Exception: + continue + try: + index = await session.index() + finally: + await session.close() + break + if index is None: + return None + cache[key] = {"at": time.time(), "index": { + "roots": [{k: r.get(k) for k in ("name", "available", "writable")} + for r in index.get("roots", [])], + "dirs": index.get("dirs", []), + "entries": [{k: e.get(k) for k in ("id", "name", "path", "size")} + for e in index.get("entries", [])]}} + return Tree.from_index(cache[key]["index"]) + + +async def remote(prefix: str, action: str) -> list[str]: + from .client.paths import encode_name, handle, resolve_group + account = await _account() + if account is None: + return [] + cache = _cache_read() + try: + groups = await _groups(account, cache) + if "/" not in prefix: + low = prefix.lower() + return [handle(g) + "/" for g in groups if handle(g).lower().startswith(low)] + spec, _, rest = prefix.partition("/") + group = resolve_group(groups, spec.replace("%2F", "/").replace("%25", "%")) + tree = await _tree(account, group, cache) + if tree is None: + return [] + folder, _, partial = rest.rpartition("/") + folder_path = folder.replace("%2F", "/").replace("%25", "%") + kids = tree.children(folder_path) if (not folder or tree.is_dir(folder_path)) else [] + base = f"{spec}/{folder}/" if folder else f"{spec}/" + out = [] + for kid in kids: + if not kid.name.lower().startswith(partial.lower()): + continue + if not kid.is_dir and action in ("mkdir", "put"): + continue # a destination is a folder + out.append(base + encode_name(kid.name) + ("/" if kid.is_dir else "")) + return sorted(out, key=str.lower) + finally: + _cache_write(cache) + await account.close() + await account.hub.aclose() + + +async def _invitations() -> list[str]: + from .client.paths import handle + account = await _account() + if account is None: + return [] + try: + return [handle(g) for g in await account.call(account.hub.invitations)] + finally: + await account.close() + await account.hub.aclose() + + +def _pins() -> list[str]: + from .client.store import Store + store = Store() + with contextlib.suppress(Exception): + if store.load(): + return sorted(store.state.get("pins", {})) + return [] + + +def candidates(shell: str, cword: int, words: list[str]) -> list[str]: + from .__main__ import build_parser + words = [unquote(w, shell) for w in words] + current = words[cword] if cword < len(words) else "" + kind = plan(words, cword, build_parser()) + + def matching(items): + return [c for c in items if c.startswith(current)] + + if kind[0] == "words": + return matching(kind[1]) + if kind[0] == "files": + return [FILES] + if kind[0] == "pins": + return matching(_pins()) + if kind[0] in ("remote", "files+remote", "groups", "invitations"): + async def go(): + if kind[0] == "invitations": + return matching(await _invitations()) + found = await remote(current, kind[1] if len(kind) > 1 else "") + if kind[0] == "groups": + return [c[:-1] for c in found if c.count("/") == 1] + return found + try: + found = asyncio.run(asyncio.wait_for(go(), DEADLINE)) + except Exception: + found = [] + return ([FILES] if kind[0] == "files+remote" else []) + found + return [] + + +def main(argv: list[str]) -> int: + """`__complete SHELL CWORD WORD...`: never fails, never writes to stderr.""" + logging.disable(logging.CRITICAL) + try: + shell, cword, words = argv[0], int(argv[1]), argv[2:] + out = candidates(shell, cword, words) + except Exception: + out = [] + sys.stdout.write("".join(c + "\n" for c in out)) + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/manpage.py b/packages/meshbay-cli/src/meshbay_cli/manpage.py index c4710f3..b2e4926 100644 --- a/packages/meshbay-cli/src/meshbay_cli/manpage.py +++ b/packages/meshbay-cli/src/meshbay_cli/manpage.py @@ -171,6 +171,25 @@ says so once. ends the session on the hub, retires this machine's device key and erases everything stored. .PP +.B account register +creates an account: a new passphrase, typed twice, must have 12 characters +and be hard enough to guess, as in the application. +It prints the account's recovery key once \(em MeshBay keeps it nowhere \(em +and asks for the code e-mailed to the address, or +.B account verify +takes it later. +.B account passphrase +changes a known passphrase: the copy of your keys each node keeps for browsers +is sealed again, the same keys, under the new one, and only then is the hub +told; if the hub refuses, the copies are set back. +A node out of reach keeps its copy as it was, and this machine seals it again +the next time it connects there. +.B account reset +is for a forgotten passphrase: a code e-mailed to the account's address, a new +passphrase, and, if you have it, the recovery key, which gives back your keys +on each node; without it, a group whose keys this machine does not keep needs +its operator. +.PP The first time this machine opens a group whose node holds no copy of your keys (the application's "Browser access" turned off), it asks to be linked as a new device and prints a code. @@ -320,7 +339,9 @@ the members whose messages were read, the browser access setting), .B store.key only where the system has no key storage, and .B interactive.log -(the interactive mode's log). +(the interactive mode's log) and +.B completion\-cache.json +(the groups and what they hold, as Tab completion last read them; two minutes). .TP \fIname\fB.meshbay\-part\fR, \fIname\fB.meshbay\-part.json\fR A download in progress, next to where the file lands. @@ -348,6 +369,13 @@ nor .B \-\-password is given. .TP +.B MESHBAY_NEW_PASSWORD +The new passphrase for +.BR "account passphrase" , +when +.B \-\-new\-password\-file +is not given. +.TP .B MESHBAY_CLI_HOME The data directory, instead of the default (see FILES). .TP |