summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/devices.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/devices.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/devices.py101
1 files changed, 101 insertions, 0 deletions
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")