aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/rewrap.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/client/rewrap.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/client/rewrap.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/rewrap.py120
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()