aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/device.py
blob: 8951dbb5c1dc7c84eb3aba297ae8efcf8038d77a (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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
"""
Device linking transcripts (MNP).

Identity keys are per node, so a person who uses a browser and a desktop client
holds two keys on the same node. The node has to admit the second without
asking an operator for a code every time — and without letting the hub, or
itself, decide which key belongs to whom.

The authority is **a key the node already pinned**. An existing device
countersigns the new one, which the hub cannot do: it has stored no user keys
since 2026-08-14, so device linking adds nothing a hub can reach.

The approval is bound by a **one-time code the new device generates and
displays**, hashed together with its own keys:

    code_hash = sha256(code ‖ pk_ed25519 ‖ pk_x25519)

That binding is the part worth understanding. The approver types the code, is
handed candidate keys, and recomputes the hash — so a node that returned
different keys produces no match and the client refuses before signing. Nothing
here rests on a human comparing digits, which is the ritual Phase 12.1 dropped
as "correct, unusable as the default"; reintroducing it through the back door
would be the same mistake.

Fields are length-prefixed and domain-separated, per L4 — the same rule as
`handshake.py`, `join.py` and `adminop.py`. `nonce_node` is the handshake nonce
of the connection carrying the message, so neither signature can be lifted onto
another connection, and `node_pk` binds an authorization to one node.

See `docs/desktop-client-v1.md` §4.
"""

from __future__ import annotations

import hashlib

DEVICE_REQUEST_PREFIX = b"meshbay:device_req:v1"
DEVICE_ADD_PREFIX = b"meshbay:device_add:v1"

# Same as the join and admin transcripts: interactive exchanges that complete in
# milliseconds, so anything older is a replay.
DEVICE_TTL = 120  # seconds

# 40 bits, single use, and bound to the keys it was generated beside. Guessing is
# bounded the same way an invitation is — a handful of attempts per connection
# and a node-wide lockout.
DEVICE_CODE_BITS = 40


def device_code_hash(code: str, pk_ed25519_b64: str, pk_x25519_b64: str) -> str:
    """
    The lookup key for a pending device request.

    Both keys go in, so the hash identifies *this device asking with this code*
    rather than *this code*. A node cannot answer an approver with a substituted
    key: the approver recomputes this from what it typed and what it was given,
    and looks the request up by the result.

    Normalized the same way pairing codes are (`roster.normalize_code`), which
    is applied by the caller — this function hashes exactly what it is given, so
    both ends have to agree on the normalized form and neither can quietly
    differ.
    """
    payload = "\x1f".join((code, pk_ed25519_b64, pk_x25519_b64))
    return hashlib.sha256(payload.encode()).hexdigest()


def _pack(prefix: bytes, fields: list[bytes]) -> bytes:
    out = bytearray(prefix)
    for field in fields:
        out += len(field).to_bytes(4, "big")
        out += field
    return bytes(out)


def device_request_transcript(
    node_pk_b64: str,
    user_id: str,
    pk_ed25519_b64: str,
    pk_x25519_b64: str,
    code_hash: str,
    nonce_node: bytes,
    ts: int,
) -> bytes:
    """
    Signed by the **new** device, proving it holds the keys it is presenting.

    Proof of possession only: this establishes nothing about whose account the
    keys belong to. That is what the countersignature below is for.
    """
    return _pack(DEVICE_REQUEST_PREFIX, [
        node_pk_b64.encode(),
        user_id.encode(),
        pk_ed25519_b64.encode(),
        pk_x25519_b64.encode(),
        code_hash.encode(),
        nonce_node,
        str(ts).encode(),
    ])


def device_add_transcript(
    node_pk_b64: str,
    user_id: str,
    pk_ed25519_b64: str,
    pk_x25519_b64: str,
    nonce_node: bytes,
    ts: int,
) -> bytes:
    """
    Signed by an **already-pinned** device, admitting the new keys.

    Deliberately does not include the code: the code is a bearer secret used to
    find the request, never signed and never echoed. What is signed is the pair
    of keys being admitted, so a signature collected for one device cannot admit
    another.
    """
    return _pack(DEVICE_ADD_PREFIX, [
        node_pk_b64.encode(),
        user_id.encode(),
        pk_ed25519_b64.encode(),
        pk_x25519_b64.encode(),
        nonce_node,
        str(ts).encode(),
    ])