summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/device.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common/src/meshbay_common/device.py')
-rw-r--r--packages/meshbay-common/src/meshbay_common/device.py125
1 files changed, 125 insertions, 0 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/device.py b/packages/meshbay-common/src/meshbay_common/device.py
new file mode 100644
index 0000000..8951dbb
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/device.py
@@ -0,0 +1,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(),
+ ])