From 988a0eecd522431980174910db28e45a63ff2710 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 12:32:24 +0200 Subject: feat(cli): account, rm -r and mkdir, Tab completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../meshbay-cli/src/meshbay_cli/client/session.py | 144 +++++++++++++++++---- 1 file changed, 120 insertions(+), 24 deletions(-) (limited to 'packages/meshbay-cli/src/meshbay_cli/client/session.py') 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 " -- cgit v1.2.3