diff options
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client')
8 files changed, 394 insertions, 38 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/account.py b/packages/meshbay-cli/src/meshbay_cli/client/account.py index d620f6d..9bb85ce 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/account.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/account.py @@ -163,11 +163,14 @@ class Account: # A node's key does not depend on who signed in. "pins": previous.get("pins", {}) if previous.get("hub") == self.hub.url else {}, } + if same_account and previous.get("browser_access"): + state["browser_access"] = previous["browser_access"] secrets = { "access_token": token, "access_exp": time.time() + int(r.get("expires_in", 0)), "refresh_token": r["refresh_token"], "master": b64(master), + "pepper_version": int(r.get("bundle_pepper_version") or 1), "legacy": b64(a), "identities": self.secrets.get("identities", {}) if same_account else {}, } @@ -306,6 +309,38 @@ class Account: secrets.setdefault("identities", {})[node_pk] = p["keys"] await self.update(move) + # An invitation's code waiting for its node: kept when the node was not + # reachable at `join`, sent by the next connection to it, dropped once used + # or refused. A link's code names its node; an invitation's does not. + + def join_code(self, group_id: str, node_pk: str) -> str | None: + c = self.secrets.get("join_codes", {}).get(group_id) + if not c or (c.get("node_pk") and c["node_pk"] != node_pk): + return None + return c["code"] + + async def keep_join_code(self, group_id: str, code: str, node_pk: str | None) -> None: + def put(state: dict, secrets: dict) -> None: + secrets.setdefault("join_codes", {})[group_id] = {"code": code, + "node_pk": node_pk} + await self.update(put) + + async def drop_join_code(self, group_id: str) -> None: + def drop(state: dict, secrets: dict) -> None: + secrets.get("join_codes", {}).pop(group_id, None) + if group_id in self.secrets.get("join_codes", {}): + await self.update(drop) + + @property + def browser_access(self) -> str | None: + """This machine's setting: "on", "off", or None (unset — leave the node + as it is). Set only by `browser-access`; never from the hub.""" + return self.state.get("browser_access") + + @property + def pepper_version(self) -> int: + return int(self.secrets.get("pepper_version") or 1) + def pin(self, node_id: str) -> str | None: return self.state.get("pins", {}).get(node_id) diff --git a/packages/meshbay-cli/src/meshbay_cli/client/devices.py b/packages/meshbay-cli/src/meshbay_cli/client/devices.py new file mode 100644 index 0000000..cb30701 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/devices.py @@ -0,0 +1,101 @@ +""" +This account's devices on one node: what the application's "Your devices on this +node" does (docs/MESHBAY_DESIGN.md §3.3). + +Every message here is sent on an authenticated session and signed by this +machine's key on the node, over the connection's nonce: the node admits or +retires a key only on the word of a device it already pinned. +""" + +import time + +from meshbay_common import MNP_VERSION +from meshbay_common.device import ( + device_add_transcript, + device_code_hash, + device_revoke_transcript, +) + +from ..errors import NOT_FOUND, USAGE, CliError +from .account import Account +from .keys import b64 +from .session import GroupSession + + +def normalize_code(code: str) -> str: + """ + A code as typed, folded onto what was generated — `crypto.js` `normalizeCode` + and the node's `roster.normalize_code`: case, dashes and spaces do not count, + and the letters left out of the alphabet read as the digits they resemble. + """ + fold = {"I": "1", "L": "1", "O": "0", "U": "V"} + return "".join(fold.get(c, c) for c in code.upper() if c not in "- \t") + + +async def list_devices(session: GroupSession) -> dict: + """`{devices: [{pk_ed25519, label, pinned_at, pinned_via, is_this_one}], pending}`.""" + r = await session.ch.request({"type": "device_list", "v": MNP_VERSION}, + "device_list_result") + return {"devices": r.get("devices") or [], "pending": int(r.get("pending") or 0)} + + +async def approve(session: GroupSession, account: Account, code: str) -> str: + """ + Countersign the one pending request this code names; its Ed25519 key. + + The code never leaves this machine: each pending request is hashed with it + and must match the hash its device filed. None, or more than one, approves + nothing — filing needs only the account's token, so somebody holding the + passphrase who saw the code could file their own keys under it. + """ + raw = normalize_code(code) + if len(raw) != 8: + raise CliError(USAGE, "A linking code has 8 characters, as XXXX-XXXX.") + listed = await session.ch.request({"type": "device_lookup", "v": MNP_VERSION}, + "device_lookup_result") + matches = [r for r in listed.get("requests") or [] + if device_code_hash(raw, r["pk_ed25519"], r["pk_x25519"]) == r["code_hash"]] + if not matches: + raise CliError(NOT_FOUND, "No device is waiting with that code on this node.") + if len(matches) > 1: + raise CliError(NOT_FOUND, "More than one device is waiting with that code, so " + "none was approved. Get a new code on the device you " + "are linking.") + match = matches[0] + ts = int(time.time()) + sig = session.identity.sk_ed.sign(device_add_transcript( + node_pk_b64=session.node_pk, user_id=account.user_id, + pk_ed25519_b64=match["pk_ed25519"], pk_x25519_b64=match["pk_x25519"], + nonce_node=session.nonce_node, ts=ts)) + await session.ch.request({"type": "device_add", "v": MNP_VERSION, + "pk_ed25519": match["pk_ed25519"], + "pk_x25519": match["pk_x25519"], + "code_hash": match["code_hash"], "ts": ts, "sig": b64(sig)}, + "device_add_ack") + return match["pk_ed25519"] + + +def find_device(devices: list[dict], prefix: str) -> dict: + """The one device whose key starts with `prefix` (8 characters at least).""" + if len(prefix) < 8: + raise CliError(USAGE, "Name the device by the first 8 characters of its key " + "at least, as `devices list` shows it.") + found = [d for d in devices if d["pk_ed25519"].startswith(prefix)] + if not found: + raise CliError(NOT_FOUND, f"No device of yours on this node has a key starting " + f"with {prefix}.") + if len(found) > 1: + raise CliError(USAGE, f"Several devices have a key starting with {prefix}; " + "give more of it.") + return found[0] + + +async def revoke(session: GroupSession, account: Account, pk_ed: str) -> None: + """Retire a device of this account. The node refuses the last one.""" + ts = int(time.time()) + sig = session.identity.sk_ed.sign(device_revoke_transcript( + node_pk_b64=session.node_pk, user_id=account.user_id, pk_ed25519_b64=pk_ed, + nonce_node=session.nonce_node, ts=ts)) + await session.ch.request({"type": "device_revoke", "v": MNP_VERSION, + "pk_ed25519": pk_ed, "ts": ts, "sig": b64(sig)}, + "device_add_ack") diff --git a/packages/meshbay-cli/src/meshbay_cli/client/groups.py b/packages/meshbay-cli/src/meshbay_cli/client/groups.py index ac738f3..4288c32 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/groups.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/groups.py @@ -7,12 +7,15 @@ import time from ..errors import NODE, CliError from .account import Account from .paths import handle -from .session import DeviceLinkPending, GroupSession, NodeKeyChanged, connect +from .session import DeviceLinkPending, GroupSession, NodeKeyChanged, NodeRefusal, connect # How often a machine waiting for its approval tries the node again. Each try is # one offer: well inside what the hub admits per account and per node. LINK_POLL = 10 +# Join refusals about the account rather than the node: every node gives them. +ACCOUNT_REFUSALS = frozenset({"code_required", "code_invalid"}) + log = logging.getLogger(__name__) @@ -20,22 +23,38 @@ async def my_groups(account: Account) -> list[dict]: return await account.call(account.hub.my_groups) -async def open_group(account: Account, group: dict) -> GroupSession: +async def open_group(account: Account, group: dict, *, join_code: str | None = None, + node_pk: str | None = None) -> GroupSession: """ A session with a node serving `group`: the pinned one first, then the others in the hub's order. A node whose key changed stops everything — that is an alarm, not a reason to quietly try its neighbour. + + `join_code` goes in the join; `node_pk` restricts the tries to that node — an + invitation link's code is for the node the link names and no other. """ nodes = await account.call(account.hub.group_nodes, group["id"]) + if node_pk: + nodes = [n for n in nodes if n.get("pk_node") == node_pk] + if not nodes: + raise CliError(NODE, f"The node the invitation names for {handle(group)} " + "is not online.") if not nodes: raise CliError(NODE, f"No node serving {handle(group)} is online.") nodes = sorted(nodes, key=lambda n: account.pin(n["node_id"]) is None) failures = [] for node in nodes: try: - return await _connect_linking(account, group, node) + return await _connect_linking(account, group, node, join_code) except (NodeKeyChanged, DeviceLinkPending): raise + except NodeRefusal as e: + # A refusal about this account — a code to give — is the same answer + # from every node; trying the next would only bury it. + if e.refusal_code in ACCOUNT_REFUSALS or len(nodes) == 1: + raise + log.info("node %s: %s", node["node_id"], e.message) + failures.append(f"{node['node_id']}: {e.message}") except CliError as e: if e.code != NODE or len(nodes) == 1: raise @@ -45,7 +64,8 @@ async def open_group(account: Account, group: dict) -> GroupSession: + "\n ".join(failures)) -async def _connect_linking(account: Account, group: dict, node: dict) -> GroupSession: +async def _connect_linking(account: Account, group: dict, node: dict, + join_code: str | None = None) -> GroupSession: """ `connect`, and — when this machine is waiting to be approved as a device on that node — the wait: the code is shown once (`account.on_device_code`), and @@ -55,7 +75,7 @@ async def _connect_linking(account: Account, group: dict, node: dict) -> GroupSe shown = False while True: try: - return await connect(account, group["id"], node) + return await connect(account, group["id"], node, join_code) except DeviceLinkPending as pending: hook = account.on_device_code wait = hook(group, pending, first=not shown) if hook else False diff --git a/packages/meshbay-cli/src/meshbay_cli/client/hub.py b/packages/meshbay-cli/src/meshbay_cli/client/hub.py index c8ce0c1..94d22da 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/hub.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/hub.py @@ -1,9 +1,10 @@ """ The hub's API, as a member's client uses it. -The hub signs in, lists groups and the nodes serving them, mints the token a -node is shown, and relays the WebRTC offer. Nothing else: no listing, no file -and no byte of content passes through it (docs/MESHBAY_DESIGN.md §1.3, §8.4). +The hub signs in, lists groups and the nodes serving them, keeps memberships +(invitations, links, leaving) and preferences, mints the token a node is shown, +and relays the WebRTC offer. Nothing else: no listing, no file and no byte of +content passes through it (docs/MESHBAY_DESIGN.md §1.3, §8.4). """ import logging @@ -147,6 +148,40 @@ class Hub: raise CliError(NODE, f"The node did not answer the offer: {e.detail}") from e return r["sdp"] + # ── Joining and leaving ────────────────────────────────────────────────── + + async def invitations(self, token: str) -> list[dict]: + return (await self._call("GET", "/v1/groups/invitations", + token=token))["invitations"] + + async def accept_invitation(self, token: str, group_id: str) -> dict: + return await self._call("POST", f"/v1/groups/{group_id}/invitation/accept", + token=token) + + async def decline_invitation(self, token: str, group_id: str) -> dict: + return await self._call("POST", f"/v1/groups/{group_id}/invitation/decline", + token=token) + + async def preview_link(self, token: str, ticket: str) -> dict: + return await self._call("POST", "/v1/invite-links/preview", token=token, + json={"ticket": ticket}) + + async def redeem_link(self, token: str, ticket: str) -> dict: + return await self._call("POST", "/v1/invite-links/redeem", token=token, + json={"ticket": ticket}) + + async def leave_group(self, token: str, group_id: str) -> dict: + return await self._call("POST", f"/v1/groups/{group_id}/leave", token=token) + + # ── Preferences ────────────────────────────────────────────────────────── + + async def preferences(self, token: str) -> dict: + return await self._call("GET", "/v1/users/me/preferences", token=token) + + async def set_preference(self, token: str, key: str, value: str) -> dict: + return await self._call("PUT", f"/v1/users/me/preferences/{key}", token=token, + json={"value": value}) + def _sign_in_refusal(e: HubError) -> CliError: if e.status == 401: diff --git a/packages/meshbay-cli/src/meshbay_cli/client/invite.py b/packages/meshbay-cli/src/meshbay_cli/client/invite.py new file mode 100644 index 0000000..6b26c97 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/invite.py @@ -0,0 +1,50 @@ +""" +Invitation links: `https://<hub>/#/invite?v=1&g=<group>&t=<ticket>&n=<node key>&c=<CODE>`. + +The shape `static/invite-link.js` reads and `api/invite_links.py` writes +(docs/MESHBAY_DESIGN.md §3.4). Two secrets: the ticket is for the hub, which +makes the first account to redeem it a member; the code is for the node the +link names, and goes to no other node and never to the hub. Every field is +checked for its exact shape, as the application checks it. +""" + +import re +from dataclasses import dataclass +from urllib.parse import parse_qs, urlsplit + +_GROUP = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +_TICKET = re.compile(r"^[A-Za-z0-9_-]{22}$") +_NODE_PK = re.compile(r"^[A-Za-z0-9_-]{43}$") +_CODE = re.compile(r"^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$") + + +@dataclass +class InviteLink: + origin: str # the hub the link was made on, '' when only the fragment was given + group_id: str + ticket: str + node_pk: str # standard base64, as the hub lists node keys + code: str + + +def node_pk_from_link(n: str) -> str: + """The link's URL-safe, unpadded key, as the hub and the transport write it.""" + b64 = n.replace("-", "+").replace("_", "/") + return b64 + "=" * (-len(b64) % 4) + + +def parse_link(text: str) -> InviteLink | None: + """The link in `text`, or None when it holds none (or a malformed one).""" + s = text.strip() + at = s.find("/invite?") + if at < 0: + return None + q = {k: v[0] for k, v in parse_qs(s[at + len("/invite?"):]).items()} + g, t, n = q.get("g", ""), q.get("t", ""), q.get("n", "") + c = q.get("c", "").upper() + if q.get("v") != "1" or not (_GROUP.match(g) and _TICKET.match(t) + and _NODE_PK.match(n) and _CODE.match(c)): + return None + parts = urlsplit(s) + origin = f"{parts.scheme}://{parts.netloc}" if parts.scheme and parts.netloc else "" + return InviteLink(origin, g, t, node_pk_from_link(n), c) diff --git a/packages/meshbay-cli/src/meshbay_cli/client/keys.py b/packages/meshbay-cli/src/meshbay_cli/client/keys.py index faca01e..ecf44a5 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/keys.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/keys.py @@ -10,6 +10,7 @@ look like an error — it looks like an account nobody can open. import base64 import hashlib import json +import os from dataclasses import dataclass from argon2.low_level import Type, hash_secret_raw @@ -77,8 +78,9 @@ def open_bundle(bundle_b64: str, *, master: bytes | None, legacy: bytes | None, `MBK3` opens under the node's key derived from `M`, with the account and the node as associated data. `MBK2` opens under `A` alone; it is read so a member - is not locked out of a node still holding one, and never rewritten here — - the application or a browser replaces it. Anything older is refused by name. + is not locked out of a node still holding one, and never written — an `MBK3` + replaces it where this machine leaves bundles (`browser-access on`). Anything + older is refused by name. """ try: raw = unb64(bundle_b64) @@ -106,6 +108,33 @@ def open_bundle(bundle_b64: str, *, master: bytes | None, legacy: bytes | None, return json.loads(plain) +def seal_bundle(keys: dict, *, master: bytes, pepper_version: int, user_id: str, + node_pk: str) -> str: + """`{skEd, skX}` sealed as `MBK3`, as `keyring.js` `seal` writes it: the magic, + the pepper version, a 96-bit nonce, then AES-GCM under the node's bundle key + with the account and the node as associated data.""" + nonce = os.urandom(12) + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + plain = json.dumps({"skEd": keys["skEd"], "skX": keys["skX"]}).encode() + ct = AESGCM(node_bundle_key(master, node_pk)).encrypt(nonce, plain, aad) + return b64(BUNDLE_MAGIC + bytes([pepper_version & 0xFF]) + nonce + ct) + + +def bundle_is_current(bundle_b64: str, *, master: bytes, user_id: str, + node_pk: str) -> bool: + """Whether a bundle is `MBK3` and opens under this `M`: one sealed under an + earlier passphrase, or `MBK2`, is to be sealed again.""" + try: + raw = unb64(bundle_b64) + if raw[:4] != BUNDLE_MAGIC: + return False + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + AESGCM(node_bundle_key(master, node_pk)).decrypt(raw[5:17], raw[17:], aad) + return True + except Exception: + return False + + @dataclass class Identity: """One account's keys on one node.""" diff --git a/packages/meshbay-cli/src/meshbay_cli/client/paths.py b/packages/meshbay-cli/src/meshbay_cli/client/paths.py index 1086085..67119f9 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/paths.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/paths.py @@ -50,6 +50,15 @@ def parse(text: str) -> RemotePath | None: return RemotePath(decoded[0], decoded[1:], from_link) +def group_arg(text: str) -> str: + """A group named on the command line, as `groups` prints it (`%2F` for a `/`), + or as a link to it: the name, decoded, with its `@owner` if given.""" + path = parse(text) + if path is None or path.parts: + raise CliError(USAGE, f"Name a group, not a path: {text!r}") + return path.group + + def encode_name(name: str) -> str: """A group or file name as it is typed in a path.""" return name.replace("%", "%25").replace("/", "%2F") diff --git a/packages/meshbay-cli/src/meshbay_cli/client/session.py b/packages/meshbay-cli/src/meshbay_cli/client/session.py index 1ea398a..d5264a2 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/session.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/session.py @@ -9,12 +9,15 @@ from the handshake on goes straight to the node, sealed under the group key 2. the DataChannel, and the channel binding from both DTLS fingerprints; 3. the handshake: the node signs its challenge with the key it announces, and that key must be the one pinned for this node, if any (§5.2, TOFU); -4. this account's identity on the node — kept from an earlier run, or opened - from the bundle the node keeps for browsers; -5. the group key, wrapped by the node for that identity (`join_request`); +4. this account's identity on the node — kept from an earlier run, opened + from the bundle the node keeps for browsers, or new (an invitation's code, + or device linking); +5. the group key, wrapped by the node for that identity (`join_request`, with + the invitation's code when there is one); 6. the mutual proof, the ack verified and unsealed — a payload that does not open ends the session, never reads as an empty group (§4.4); -7. `device_hello`, so the node knows which of the account's devices this is. +7. `device_hello`, so the node knows which of the account's devices this is; +8. what `browser-access` says to leave on the node: a sealed copy, or none. Every request carries a `req_id` and its reply is matched on it (§5.3): matching by arrival order is a guess that fails silently, and against the unrelated @@ -60,7 +63,15 @@ from meshbay_common.join import join_transcript from ..errors import NODE, NODE_KEY, VERSION, CliError from .account import Account -from .keys import BundleError, Identity, b64, open_bundle, unb64 +from .keys import ( + BundleError, + Identity, + b64, + bundle_is_current, + open_bundle, + seal_bundle, + unb64, +) log = logging.getLogger(__name__) @@ -185,7 +196,8 @@ class GroupSession: """An authenticated connection to one node, for one group.""" def __init__(self, pc: RTCPeerConnection, ch: Channel, group_id: str, - node_id: str, node_pk: str, gek: bytes, ack: dict): + node_id: str, node_pk: str, gek: bytes, ack: dict, + identity: Identity | None = None, nonce_node: bytes = b""): self.pc = pc self.ch = ch self.group_id = group_id @@ -193,6 +205,10 @@ class GroupSession: self.node_pk = node_pk self.gek = gek self.ack = ack + # What a device message is signed with: this machine's key on the node, + # and the nonce that ties a signature to this connection. + self.identity = identity + self.nonce_node = nonce_node async def index(self) -> dict: """`{version, entries, dirs, roots}`, opened under the group key.""" @@ -208,8 +224,13 @@ class GroupSession: await self.pc.close() -async def connect(account: Account, group_id: str, node: dict) -> GroupSession: - """Open a session with `node` (a `/v1/groups/{id}/nodes` row) for `group_id`.""" +async def connect(account: Account, group_id: str, node: dict, + join_code: str | 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 + account yet; otherwise one kept for this group and node is sent. + """ node_id, listed_pk = node["node_id"], node["pk_node"] pinned = account.pin(node_id) if pinned and pinned != listed_pk: @@ -236,7 +257,7 @@ async def connect(account: Account, group_id: str, node: dict) -> GroupSession: "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) + mnp_token, binding, join_code) except BaseException: with contextlib.suppress(Exception): await pc.close() @@ -244,7 +265,8 @@ async def connect(account: Account, group_id: str, node: dict) -> GroupSession: async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: str, - listed_pk: str, mnp_token: str, binding: bytes) -> GroupSession: + listed_pk: str, mnp_token: str, binding: bytes, + join_code: str | None) -> GroupSession: nonce_c = os.urandom(32) challenge = await ch.request( {"type": "handshake", "v": MNP_VERSION, "v_min": MNP_MIN_SUPPORTED, @@ -266,24 +288,39 @@ 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 = await _identity(account, ch, node_pk) + identity, source, bundle = await _identity(account, ch, node_pk) + if bundle is None and account.browser_access: + # 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. + bundle = await _fetch_bundle(ch) + code = join_code or account.join_code(group_id, node_pk) or "" ts = int(time.time()) joined = await ch.request({ "type": "join_request", "v": MNP_VERSION, "group_id": group_id, - "pk_ed25519": identity.pk_ed, "pk_x25519": identity.pk_x, "code": "", "ts": ts, + "pk_ed25519": identity.pk_ed, "pk_x25519": identity.pk_x, "code": code, "ts": ts, "sig": b64(identity.sk_ed.sign(join_transcript( node_pk_b64=node_pk, group_id=group_id, user_id=account.user_id, pk_ed25519_b64=identity.pk_ed, pk_x25519_b64=identity.pk_x, nonce_node=nonce_s, ts=ts)))}, "join_result") if not joined.get("ok") or not joined.get("gek"): reason = str(joined.get("reason") or "") + 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": 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) if source == "pending": await account.adopt_pending(node_pk) + elif source == "new": + # Admitted with a code, or by an open group: these keys are now pinned + # there, and an identity not kept would strand the account on the node. + await account.keep_identity(node_pk, identity) + if code: + await account.drop_join_code(group_id) gek = unwrap_gek_aes(joined, identity.sk_x.private_bytes_raw(), identity.sk_x.public_key().public_bytes_raw()) @@ -312,40 +349,80 @@ 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) - return GroupSession(pc, ch, group_id, node_id, node_pk, gek, config) + await _settle_bundle(account, ch, identity, node_pk, bundle) + return GroupSession(pc, ch, group_id, node_id, node_pk, gek, config, + identity, nonce_s) + + +async def _fetch_bundle(ch: Channel) -> str: + """The node's copy of this account's keys, or "" 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 "" -async def _identity(account: Account, ch: Channel, node_pk: str) -> tuple[Identity, str]: +async def _identity(account: Account, ch: Channel, + node_pk: str) -> tuple[Identity, str, str | None]: """ - This account's keys on this node, and where they came from: kept from an - earlier run, waiting for a device approval, opened from the copy the node - keeps for browsers — or, failing all three, new, for device linking. + This account's keys on this node, where they came from, and the node's bundle + when it was fetched (None: not asked). Kept from an earlier run, waiting for + a device approval, opened from the copy the node keeps for browsers — or, + failing all three, new: for an invitation's code, or for device linking. """ kept = account.identity(node_pk) if kept is not None: - return kept, "kept" + return kept, "kept", None pending = account.pending_identity(node_pk) if pending is not None: - return pending.identity, "pending" - found = await ch.request({"type": "keypair_bundle_fetch", "v": MNP_VERSION}, - "keypair_bundle_resp") - if not found.get("found"): - return Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()), "new" + return pending.identity, "pending", None + bundle = await _fetch_bundle(ch) + if not bundle: + return (Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()), + "new", bundle) try: - keys = open_bundle(found["bundle_enc"], master=account.master, + keys = open_bundle(bundle, master=account.master, legacy=account.legacy, user_id=account.user_id, node_pk=node_pk) identity = Identity.from_pkcs8(keys) except BundleError as e: raise CliError(NODE, str(e)) from e await account.keep_identity(node_pk, identity) - return identity, "bundle" + return identity, "bundle", bundle + + +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. + """ + setting = account.browser_access + if not setting or bundle is None: + return + try: + 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( + 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") + 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) # What a refused join means, for the person reading it. JOIN_REFUSALS = { - "code_required": "this node has not admitted you yet. Accept the invitation in the " - "MeshBay application and enter its code there; the command line " - "does not join groups", + "code_required": "this node has not admitted you yet; it needs the code of your " + "invitation: meshbay-cli join GROUP --code CODE", + "code_invalid": "the invitation's code was refused: already used, cancelled, or " + "mistyped. Ask the group's owner for a new one", "unknown_device": "the key this node holds for you is not one of your devices there " "any more; ask its operator, or approve this machine from a device " "already linked", |