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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
|
"""
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"
DEVICE_HELLO_PREFIX = b"meshbay:device_hello: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(),
])
def device_hello_transcript(
node_pk_b64: str,
group_id: str,
user_id: str,
pk_ed25519_b64: str,
nonce_node: bytes,
ts: int,
) -> bytes:
"""
Signed by the device on an already-authenticated connection, saying **which
of the account's devices this connection is**.
The handshake proves membership of a group (a GEK-HMAC) and carries an
account from the hub's token; it proves nothing about *which* device is
talking. The node needed that the moment one account could hold several:
`_load_pinned_pk` was resolving "this account's oldest live device" and
recording it as the uploader of every file, so a phone's uploads were
attributed to a laptop.
Additive and optional. A client that does not send it leaves the node where
it was, which is why this could ship without a breaking protocol change —
but a node that *has* been told refuses a later claim to be a different
device on the same connection.
`nonce_node` is this connection's handshake nonce, so the signature cannot
be lifted onto another connection, and `node_pk` binds it to one node —
the same rule as every other transcript here.
"""
return _pack(DEVICE_HELLO_PREFIX, [
node_pk_b64.encode(),
group_id.encode(),
user_id.encode(),
pk_ed25519_b64.encode(),
nonce_node,
str(ts).encode(),
])
|