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/account.py | 9 ++ packages/meshbay-cli/src/meshbay_cli/client/hub.py | 60 +++++++++ .../meshbay-cli/src/meshbay_cli/client/keys.py | 69 ++++++++++ .../src/meshbay_cli/client/passphrase.py | 64 +++++++++ .../meshbay-cli/src/meshbay_cli/client/remove.py | 40 ++++-- .../meshbay-cli/src/meshbay_cli/client/rewrap.py | 120 +++++++++++++++++ .../meshbay-cli/src/meshbay_cli/client/session.py | 144 +++++++++++++++++---- 7 files changed, 468 insertions(+), 38 deletions(-) create mode 100644 packages/meshbay-cli/src/meshbay_cli/client/passphrase.py create mode 100644 packages/meshbay-cli/src/meshbay_cli/client/rewrap.py (limited to 'packages/meshbay-cli/src/meshbay_cli/client') diff --git a/packages/meshbay-cli/src/meshbay_cli/client/account.py b/packages/meshbay-cli/src/meshbay_cli/client/account.py index 96483e9..fae1add 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/account.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/account.py @@ -89,6 +89,13 @@ class Account: m = self.secrets.get("master") return unb64(m) if m else None + @property + def previous_masters(self) -> list[bytes]: + """The `M` of passphrases this machine changed away from: a copy a node + still holds under one is carried over at the next connection.""" + current = self.secrets.get("master") + return [unb64(m) for m in self.secrets.get("previous_masters", []) if m != current] + @property def legacy(self) -> bytes | None: a = self.secrets.get("legacy") @@ -175,6 +182,8 @@ class Account: "pepper_version": int(r.get("bundle_pepper_version") or 1), "legacy": b64(a), "identities": self.secrets.get("identities", {}) if same_account else {}, + "previous_masters": [m for m in self.secrets.get("previous_masters", []) + if m != b64(master)] if same_account else [], } if remember: device = (self.secrets.get("device_sk") if same_account else None) diff --git a/packages/meshbay-cli/src/meshbay_cli/client/hub.py b/packages/meshbay-cli/src/meshbay_cli/client/hub.py index 94d22da..5b301f8 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/hub.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/hub.py @@ -120,6 +120,66 @@ class Hub: async def me(self, token: str) -> dict: return await self._call("GET", "/v1/users/me", token=token) + # ── The account ────────────────────────────────────────────────────────── + + async def captcha(self) -> dict | None: + """A proof-of-work challenge, or None when this hub has the check off.""" + try: + return await self._call("GET", "/v1/users/captcha") + except HubError as e: + if e.status == 404: + return None + raise + + async def register(self, username: str, email: str, auth_key: str, + recovery_key: str | None, captcha_token: str | None) -> dict: + body = {"username": username, "email": email, "auth_key": auth_key} + if recovery_key: + body["recovery_key"] = recovery_key + if captcha_token: + body["captcha_token"] = captcha_token + return await self._call("POST", "/v1/users/register", json=body) + + async def verify_email(self, email: str, code: str) -> dict: + return await self._call("POST", "/v1/users/verify-email", + json={"email": email, "code": code}) + + async def bundle_pepper(self, token: str, auth_key: str) -> dict: + return await self._call("POST", "/v1/users/me/bundle-pepper", token=token, + json={"auth_key": auth_key}) + + async def change_passphrase(self, token: str, old_auth_key: str, + new_auth_key: str) -> dict: + return await self._call("POST", "/v1/users/password", token=token, + json={"old_auth_key": old_auth_key, + "new_auth_key": new_auth_key}) + + async def reset_request(self, username: str, email: str, + captcha_token: str | None) -> dict: + body = {"username": username, "email": email} + if captcha_token: + body["captcha_token"] = captcha_token + return await self._call("POST", "/v1/users/password/reset-request", json=body) + + async def reset_passphrase(self, username: str, code: str, new_auth_key: str) -> dict: + return await self._call("POST", "/v1/users/password/reset", json={ + "username": username, "code": code, "new_auth_key": new_auth_key}) + + async def change_email(self, token: str, email: str, auth_key: str) -> dict: + return await self._call("PATCH", "/v1/users/me", token=token, + json={"email": email, "auth_key": auth_key}) + + async def verify_email_change(self, token: str, code: str) -> dict: + return await self._call("POST", "/v1/users/verify-email-change", token=token, + json={"code": code}) + + async def revoke_sessions(self, token: str) -> dict: + return await self._call("POST", "/v1/users/me/sessions/revoke", token=token) + + async def delete_account(self, token: str, auth_key: str) -> dict: + return await self._call("DELETE", "/v1/users/me", token=token, + json={"auth_key": auth_key}) + # ── Groups and nodes ───────────────────────────────────────────────────── async def my_groups(self, token: str) -> list[dict]: diff --git a/packages/meshbay-cli/src/meshbay_cli/client/keys.py b/packages/meshbay-cli/src/meshbay_cli/client/keys.py index ecf44a5..2ac6f2c 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/keys.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/keys.py @@ -135,6 +135,75 @@ def bundle_is_current(bundle_b64: str, *, master: bytes, user_id: str, return False +def opens_under(bundle_b64: str, master: bytes, *, user_id: str, + node_pk: str) -> dict | None: + """The `{skEd, skX}` of an `MBK3` bundle sealed under `master`, else None.""" + try: + raw = unb64(bundle_b64) + if raw[:4] != BUNDLE_MAGIC: + return None + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + return json.loads(AESGCM(node_bundle_key(master, node_pk)).decrypt( + raw[5:17], raw[17:], aad)) + except Exception: + return None + + +# ── The recovery key (docs/MESHBAY_DESIGN.md §3.6) ─────────────────────────── +# +# 32 random bytes `R`, shown as 13 groups of four Base32 characters; the key +# that seals a recovery copy is HKDF(R, "meshbay:recovery:v1:" + username), +# used as it is — not per node — with the bundle's associated data and pepper +# version 0. As `keyderive.js` `generateRecoveryKey`/`deriveRecoveryKey`. + +_B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + + +def generate_recovery() -> tuple[bytes, str]: + """`R`, and the grouped mnemonic a person keeps.""" + raw = os.urandom(32) + return raw, recovery_mnemonic(raw) + + +def recovery_mnemonic(raw: bytes) -> str: + text = base64.b32encode(raw).decode().rstrip("=") + return " ".join(text[i:i + 4] for i in range(0, len(text), 4)) + + +def recovery_bytes(mnemonic: str) -> bytes: + """`R` from its mnemonic, however spaced or cased; ValueError if it is not one.""" + clean = "".join(c for c in mnemonic.upper() if c.isalnum()) + if any(c not in _B32 for c in clean): + raise ValueError("a recovery key is letters A to Z and digits 2 to 7") + raw = base64.b32decode(clean + "=" * (-len(clean) % 8)) if clean else b"" + if len(raw) < 32: + raise ValueError("this recovery key is too short") + return raw[:32] + + +def recovery_key(raw: bytes, username: str) -> bytes: + return hkdf(raw, f"meshbay:recovery:v1:{username}") + + +def seal_recovery(keys: dict, *, rkey: bytes, user_id: str, node_pk: str) -> str: + nonce = os.urandom(12) + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + plain = json.dumps({"skEd": keys["skEd"], "skX": keys["skX"]}).encode() + return b64(BUNDLE_MAGIC + bytes([0]) + nonce + AESGCM(rkey).encrypt(nonce, plain, aad)) + + +def open_recovery(bundle_b64: str, *, rkey: bytes, user_id: str, node_pk: str) -> dict: + try: + raw = unb64(bundle_b64) + if raw[:4] != BUNDLE_MAGIC: + raise ValueError + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + return json.loads(AESGCM(rkey).decrypt(raw[5:17], raw[17:], aad)) + except Exception as e: + raise BundleError("the node's recovery copy of your keys does not open with " + "this recovery key") from e + + @dataclass class Identity: """One account's keys on one node.""" diff --git a/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py b/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py new file mode 100644 index 0000000..071c708 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py @@ -0,0 +1,64 @@ +""" +A new passphrase's floor, and the hub's proof of work — both checked here. + +The hub never sees a passphrase (docs/MESHBAY_DESIGN.md §3.1), so the floor — +12 characters and about 60 estimated bits — can only be enforced by the client: +`auth-page.js` `passwordBits`, the same estimate, so a passphrase the page +refuses is refused here too. +""" + +import hashlib +import math +import re + +MIN_LENGTH = 12 +MIN_BITS = 60 +_WEAK = re.compile(r"(password|motdepasse|azerty|qwerty|123456|meshbay)", re.I) + + +def passphrase_bits(pw: str) -> int: + if not pw: + return 0 + pool = 0 + if re.search(r"[a-z]", pw): + pool += 26 + if re.search(r"[A-Z]", pw): + pool += 26 + if re.search(r"[0-9]", pw): + pool += 10 + if re.search(r"[^A-Za-z0-9]", pw): + pool += 32 + # JavaScript counts UTF-16 units; a character outside the BMP is two. + length = len(pw.encode("utf-16-le")) // 2 + bits = length * math.log2(pool or 1) + if len(set(pw)) < length / 2: + bits *= 0.6 + if re.fullmatch(r"[0-9]+", pw): + bits *= 0.5 + if _WEAK.search(pw): + bits *= 0.3 + # Math.round: halves go up. + return math.floor(bits + 0.5) + + +def passphrase_problem(pw: str) -> str | None: + """Why a new passphrase is refused, or None.""" + if len(pw.encode("utf-16-le")) // 2 < MIN_LENGTH: + return f"A passphrase has {MIN_LENGTH} characters at least." + if passphrase_bits(pw) < MIN_BITS: + return ("This passphrase is too easy to guess: make it longer, or mix in " + "other kinds of characters.") + return None + + +def solve_captcha(challenge: dict) -> str | None: + """ + The answer to `GET /v1/users/captcha`: the number below `max_number` whose + `` hashes to `challenge` (`captcha.js` `solve`), as the token + the register and reset-request bodies carry. None if there is none. + """ + prefix, want = challenge["prefix"], bytes.fromhex(challenge["challenge"]) + for n in range(int(challenge["max_number"])): + if hashlib.sha256(f"{prefix}{n}".encode()).digest() == want: + return f"{prefix}{n}.{challenge['signature']}" + return None diff --git a/packages/meshbay-cli/src/meshbay_cli/client/remove.py b/packages/meshbay-cli/src/meshbay_cli/client/remove.py index 370cf95..ed9c65a 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/remove.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/remove.py @@ -1,15 +1,17 @@ """ -Deleting a file from a group (docs/MESHBAY_DESIGN.md §5.4). +Deleting a file, or an empty folder, from a group (docs/MESHBAY_DESIGN.md §5.4). The node decides, and on two grounds only: a key paired with it as operator, or a live device of the account that uploaded the file — any of them, so a file sent from a phone is deleted from the laptop. Being the group's owner on the hub -is neither. The request is answered with a challenge the client signs with its -key on the node; the node rebuilds what was signed itself. +is neither. A folder has no uploader: only the operator removes one, and only +an empty one — the node never deletes recursively. The request is answered with +a challenge the client signs with its key on the node; the node rebuilds what +was signed itself. """ from meshbay_common import MNP_VERSION -from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript +from meshbay_common.adminop import OP_DIR_DELETE, OP_FILE_DELETE, admin_transcript from ..errors import NODE, CliError from .keys import b64, unb64 @@ -24,22 +26,32 @@ def may_delete(session: GroupSession, entry: dict, user_id: str) -> bool: async def delete_file(session: GroupSession, file_id: str) -> None: """Delete one file. Raises `NodeRefusal` with the node's reason.""" - reply = await session.ch.request({"type": "file_delete", "v": MNP_VERSION, - "file_id": file_id}, - "admin_challenge", "file_delete_ack") - if reply["type"] == "file_delete_ack": + await _signed(session, {"type": "file_delete", "v": MNP_VERSION, "file_id": file_id}, + OP_FILE_DELETE, file_id, "file_delete_ack") + + +async def delete_dir(session: GroupSession, path: str) -> None: + """Remove one empty folder (root/folders), as the node's operator.""" + await _signed(session, {"type": "dir_delete", "v": MNP_VERSION, "dir": path}, + OP_DIR_DELETE, path, "dir_delete_ack") + + +async def _signed(session: GroupSession, msg: dict, op: str, subject: str, + ack: str) -> None: + reply = await session.ch.request(msg, "admin_challenge", ack) + if reply["type"] == ack: return # What is signed is checked against what was asked, never taken from the - # node: a challenge for another operation, file, group or node is refused. - if (reply.get("op") != OP_FILE_DELETE or reply.get("subject") != file_id + # node: a challenge for another operation, subject, group or node is refused. + if (reply.get("op") != op or reply.get("subject") != subject or reply.get("group_id") != session.group_id or reply.get("node_pk") != session.node_pk): raise CliError(NODE, "The node asked to sign something other than this deletion; " "nothing was signed.") - transcript = admin_transcript(op=OP_FILE_DELETE, node_pk_b64=session.node_pk, - group_id=session.group_id, subject=file_id, + transcript = admin_transcript(op=op, node_pk_b64=session.node_pk, + group_id=session.group_id, subject=subject, nonce=unb64(reply["nonce"]), ts=int(reply["ts"])) await session.ch.request({"type": "admin_response", "v": MNP_VERSION, - "op_id": reply["op_id"], "op": OP_FILE_DELETE, + "op_id": reply["op_id"], "op": op, "signature": b64(session.identity.sk_ed.sign(transcript))}, - "file_delete_ack") + ack) 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() 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