summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 03:24:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 03:24:55 +0200
commit768e07046368819b8a8f15c8b21e5a8bbfcdf282 (patch)
treefba4fa5f85e3963b2281004b503be05f552aff2c /packages/meshbay-common/src/meshbay_common
parente9d5e979fdab9a1cc3c729d602e6f27207b9480c (diff)
downloadmeshbay-768e07046368819b8a8f15c8b21e5a8bbfcdf282.tar.gz
feat: device linking, and signing in to the hub with a device key
Stage C. Identity keys are per node, so a browser and a desktop client are two keys on one account there — and the node refused the second where it accepted the first. Without this, an account created natively could never be opened in a browser without an operator code per node, and "a native client must not prevent web use" would have been dead on arrival. Device linking (node) --------------------- `identities` is keyed by `(user_id, pk_ed25519)` instead of `user_id` alone. The old shape did `INSERT OR REPLACE`, so a second device overwrote the first silently; SQLite cannot change a primary key in place, so the table is rebuilt. Existing pins are carried over — verified against a live roster with 10 of them, nobody re-pairs. A new device files a request bound by `sha256(code ‖ its own keys)`, and a key the node **already pinned** countersigns it. The hub cannot: it has stored no user keys since 2026-08-14, which is what makes this safe to do without an operator in the loop. **The code never reaches the node.** It lists this account's pending requests with their stored hashes; the approver recomputes and keeps the match. A node offering fabricated keys would have to produce a hash over a code it has never seen. Nothing rests on a human comparing digits — that ritual was dropped in 12.1 as "correct, unusable as the default" and must not return by the back door. The design document had the approver look a request up *by* its hash, which is circular: computing it needs the keys being asked about. Corrected in both. Revocation marks rather than deletes, because a deleted row is a key the node would happily pin again — which is the laptop somebody just reported lost. Your last device cannot be revoked: coming back would need an operator's code. Hub — the only change in the whole plan --------------------------------------- `POST /v1/users/auth` signs in with a device Ed25519 key, on the same pattern as `/v1/nodes/auth`, plus `/v1/users/devices` to register, list and retire. New `user_devices` table with an Alembic migration, because `create_all()` is not one. This is **not** the key directory that was H3, and the tests say so: nothing reads it but the hub, no group key is ever wrapped for one, and it is a different key from the per-node identities. What it does cost is metadata — the hub now knows how many devices an account has and when each last signed in. Also `client.minimum` / `client.recommended` in `GET /v1/hub/version`: an installed client meets a newer hub the day the interface ships in a package, and that is cheap now and awkward to retrofit. Browser ------- The `key_changed` refusal becomes `unknown_device` and offers a linking code instead of telling someone to find their operator. The Members panel lists this account's devices here, approves one by code, and retires one. 773 tests pass. `e2e.py` gained a step that links a device end to end against the live deployment — file, list, recompute, countersign, then open the group with the new keys and no code — and it also gained `recv_type`, because a step that assumes the next message is its own answer reads an ack left by the step before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-common/src/meshbay_common')
-rw-r--r--packages/meshbay-common/src/meshbay_common/device.py125
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py12
2 files changed, 137 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(),
+ ])
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index a1c971f..bbdff63 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -78,6 +78,18 @@ class MNP:
MEMBER_REVOKE_ACK = "member_revoke_ack"
MEMBER_UNPIN = "member_unpin" # operator → node: forget an identity
MEMBER_UNPIN_ACK = "member_unpin_ack"
+ # Device linking. A new device files a request bound to a code it displays;
+ # an already-pinned device of the same account approves it. Neither the hub
+ # nor the node can produce the countersignature.
+ DEVICE_REQUEST = "device_add_request" # new device → node
+ DEVICE_REQUEST_ACK = "device_add_request_ack"
+ DEVICE_LOOKUP = "device_lookup" # approver → node: find by code
+ DEVICE_LOOKUP_RESULT = "device_lookup_result"
+ DEVICE_ADD = "device_add" # approver → node: countersigned
+ DEVICE_ADD_ACK = "device_add_ack"
+ DEVICE_LIST = "device_list" # anyone → node: my devices
+ DEVICE_LIST_RESULT = "device_list_result"
+ DEVICE_REVOKE = "device_revoke" # a device retires another
# Rotation is the half of revocation that revocation cannot do: the node
# generates a fresh key itself, so no key material crosses the wire.
GEK_ROTATE = "gek_rotate" # operator → node: new group key