diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 03:24:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 03:24:55 +0200 |
| commit | 768e07046368819b8a8f15c8b21e5a8bbfcdf282 (patch) | |
| tree | fba4fa5f85e3963b2281004b503be05f552aff2c /packages/meshbay-common/src | |
| parent | e9d5e979fdab9a1cc3c729d602e6f27207b9480c (diff) | |
| download | meshbay-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')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/device.py | 125 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/protocol.py | 12 |
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 |