summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/commands/account.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 12:32:24 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 12:32:24 +0200
commit988a0eecd522431980174910db28e45a63ff2710 (patch)
tree80b16f8cfe907f084fac931fa35326c7f2892a9d /packages/meshbay-cli/src/meshbay_cli/commands/account.py
parente0a1036e0fee15b3c6c83f3e935d50f472a2e776 (diff)
downloadmeshbay-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/commands/account.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/account.py488
1 files changed, 488 insertions, 0 deletions
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
+