aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/__main__.py16
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/account.py9
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/hub.py60
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/keys.py69
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/passphrase.py64
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/remove.py40
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/rewrap.py120
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/session.py144
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/account.py488
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/completion.py96
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py108
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/rm.py100
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/complete.py319
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/manpage.py30
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