summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/devices.py
blob: cb3070196afd946d00da28fc77c736c73a4795a3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
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")