""" 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()