""" 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/MESHBAY_DESIGN.md` §3.3. """ 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(), ])