From 6f68bb6c1970e26de1f66c073ea5d7fecd21840d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 00:56:04 +0200 Subject: feat(cli): invitations, join, decline, leave, devices, browser access join accepts an invitation or redeems a link pasted as sent, then has the node admit a key pair made here, with the invitation's code (a link's code goes only to the node it names; one for a node that is away is kept for the next connection). decline, leave and devices revoke ask first, and refuse without --yes where there is no terminal. devices approve countersigns the one waiting request a code matches, none when several do. browser-access is the application's setting, kept per machine and never enabled from the hub's mirror: on leaves a copy sealed under M on each node, off withdraws it, unset touches nothing. A key pair made for a first contact is now kept. Co-Authored-By: Claude Opus 5.5 --- .../meshbay-cli/src/meshbay_cli/client/devices.py | 101 +++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/meshbay-cli/src/meshbay_cli/client/devices.py (limited to 'packages/meshbay-cli/src/meshbay_cli/client/devices.py') 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") -- cgit v1.2.3