diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 12:32:24 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 12:32:24 +0200 |
| commit | 988a0eecd522431980174910db28e45a63ff2710 (patch) | |
| tree | 80b16f8cfe907f084fac931fa35326c7f2892a9d /packages/meshbay-cli/src/meshbay_cli/client/rewrap.py | |
| parent | e0a1036e0fee15b3c6c83f3e935d50f472a2e776 (diff) | |
| download | meshbay-988a0eecd522431980174910db28e45a63ff2710.tar.gz | |
feat(cli): account, rm -r and mkdir, Tab completion
account shows the account and does what the sign-up form and the Profile
page do: register (the passphrase floor and the hub's proof of work checked
here, the recovery key printed once), verify, change the passphrase, reset a
forgotten one, change the e-mail address, sign out everywhere, delete. A new
passphrase carries the copy of the account's keys each node keeps for
browsers first — opened under the old M, the same keys sealed under the new
one — and only then tells the hub; a refusal sets the copies back. A reset
restores them from the recovery copy. The connections that do it never make
keys nor ask for a device approval. This machine keeps the M it changed away
from, so a node out of reach is carried at its next connection; a copy under
an M it does not know is no longer rewritten, which locked the browsers out
after a passphrase changed elsewhere.
rm -r removes a folder and all it holds, from the node's operator key: the
files, then the folders deepest first, one confirmation that counts them.
mkdir makes folders, --parents the missing ones (not -p, the passphrase).
completion bash|powershell prints the script that makes Tab complete actions,
verbs, options and the paths of the groups this machine is signed in to,
cached two minutes; the deb and rpm packages install the bash one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/rewrap.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/rewrap.py | 120 |
1 files changed, 120 insertions, 0 deletions
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() |