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