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-node/src/meshbay_node/transport | |
| 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-node/src/meshbay_node/transport')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 329 |
1 files changed, 321 insertions, 8 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 4ec841f..9d16f82 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -65,6 +65,12 @@ from meshbay_common.adminop import ( admin_transcript, ) from meshbay_common.crypto import pk_to_b64, wrap_gek_aes +from meshbay_common.device import ( + DEVICE_TTL, + device_add_transcript, + device_code_hash, + device_request_transcript, +) from meshbay_common.join import ( JOIN_TTL, ROLE_MEMBER, @@ -453,6 +459,16 @@ class WebRTCPeerSession: self._do_invite_create(msg) elif mtype == MNP.MEMBER_REVOKE: self._do_member_revoke(msg) + elif mtype == MNP.DEVICE_REQUEST and self._nonce_node: + self._spawn(self._do_device_request(msg)) + elif mtype == MNP.DEVICE_LOOKUP: + self._spawn(self._do_device_lookup(msg)) + elif mtype == MNP.DEVICE_ADD: + self._spawn(self._do_device_add(msg)) + elif mtype == MNP.DEVICE_LIST: + self._spawn(self._do_device_list(msg)) + elif mtype == MNP.DEVICE_REVOKE: + self._spawn(self._do_device_revoke(msg)) elif mtype == MNP.MEMBER_UNPIN: self._do_member_unpin(msg) elif mtype == MNP.GEK_ROTATE: @@ -901,15 +917,31 @@ class WebRTCPeerSession: self._join_refuse("signature_invalid") return - known = await roster.get_identity(user_id) + # One person may hold several devices here — a browser and a desktop + # client are two keys on one account. So the question is not "is this + # THE key" but "is this ONE OF this account's live devices". + device = await roster.find_device(user_id, pk_ed_b64) + if device and device["pk_x25519"] != pk_x_b64: + # The Ed25519 key is pinned but arrives with a different encryption + # key. The join transcript signs both together, so this is either a + # client that regenerated half its identity or something splicing + # two messages; either way the pair is not the one admitted. + self._join_refuse( + "key_changed", + f"pinned x25519={device['pk_x25519'][:16]} presented={pk_x_b64[:16]}") + return + known = device + if not known and await roster.list_devices(user_id): + # The account is known here but this key is not one of its devices. + # Not an error to shout about: it is a second browser or a new + # client, and the way in is a device-add approved by a device that + # is already trusted — no operator, no new invitation code. + self._join_refuse( + "unknown_device", + f"presented={pk_ed_b64[:16]} — approve it from a device already " + f"paired with this node") + return if known: - if known["pk_ed25519"] != pk_ed_b64 or known["pk_x25519"] != pk_x_b64: - # The blocking warning, raised where it matters: whoever this is - # holds a different key than the person the operator paired. - self._join_refuse( - "key_changed", - f"pinned={known['pk_ed25519'][:16]} presented={pk_ed_b64[:16]}") - return # An operator's row is node-wide (empty group), so a lookup for the # group they happen to be opening finds nothing. Fall back to it, or # the client is told it has no role on a node it administers. @@ -956,6 +988,287 @@ class WebRTCPeerSession: await self._join_ok(user_id, pk_x_raw, session_group or invite["group_id"], role=invite["role"], recognised=False) + + # ── Device linking ─────────────────────────────────────────────────────── + # + # A person may hold several devices on one node. The authority admitting a + # new one is a key the node already pinned — never the hub, which has stored + # no user keys since 2026-08-14 and therefore cannot countersign anything. + # See docs/desktop-client-v1.md §4. + + async def _do_device_request(self, msg: dict) -> None: + """ + A new device files itself as pending, bound to a code it displays. + + Served in the pre-proof window: by construction the caller holds no key + this node knows, so there is nothing yet to prove. Filing is inert — + nothing is admitted until an existing device countersigns. + """ + roster = self._ctx.get("roster") + if roster is None or not self._user_id or not self._nonce_node: + self._send({"type": "error", "detail": "Not ready for a device request"}) + return + + if not self._spend_device_attempt(): + return + + pk_ed_b64 = str(msg.get("pk_ed25519", "")) + pk_x_b64 = str(msg.get("pk_x25519", "")) + code_hash = str(msg.get("code_hash", "")) + if not (pk_ed_b64 and pk_x_b64 and code_hash): + self._send({"type": "error", "detail": "Missing device keys or code"}) + return + + # The account must already be known here. Anti-spam rather than a + # security boundary: the filing key is unpinned by construction, so this + # bounds the table, not the trust. + existing = await roster.list_devices(self._user_id) + if not existing: + self._send({"type": "error", + "detail": "This account has no device on this node yet — " + "an invitation code is what admits the first"}) + return + if len(existing) >= roster.MAX_DEVICES_PER_USER: + self._send({"type": "error", + "detail": f"Already {len(existing)} devices, which is the " + f"limit. Revoke one first."}) + return + + ts = int(msg.get("ts", 0)) + if abs(time.time() - ts) > DEVICE_TTL: + self._send({"type": "error", "detail": "Device request expired"}) + return + + transcript = device_request_transcript( + node_pk_b64=self._node_pk_b64(), user_id=self._user_id, + pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64, + code_hash=code_hash, nonce_node=self._nonce_node, ts=ts) + try: + pk_ed = Ed25519PublicKey.from_public_bytes(base64.b64decode(pk_ed_b64)) + sig = base64.b64decode(msg.get("sig", "")) + except Exception: + self._send({"type": "error", "detail": "Invalid device key encoding"}) + return + if not self._verify_sig(pk_ed, transcript, sig): + # Proof of possession, and nothing more: this says the caller holds + # the keys, never that they belong to this account. + self._send({"type": "error", "detail": "Device signature invalid"}) + return + + ttl = int(self._ctx.get("device_request_ttl") or 3600) + expires = await roster.file_device_request( + user_id=self._user_id, username=self._username or "", + pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, + code_hash=code_hash, ttl=ttl) + self._audit("device_request", f"{pk_ed_b64[:16]}") + log.info("Device request filed for %s (%s)", self._user_id[:8], + pk_ed_b64[:16]) + self._send({"type": MNP.DEVICE_REQUEST_ACK, "v": MNP_VERSION, + "expires_at": expires}) + + async def _do_device_lookup(self, msg: dict) -> None: + """ + List this account's pending device requests, each with its code hash. + + **The node never learns the code**, which is what makes it unable to + substitute a key. It answers with candidates; the approver recomputes + `sha256(code ‖ keys)` for each and keeps the one that matches. A node + offering fabricated keys would have to produce a hash matching + `sha256(code ‖ fabricated)` — and it does not know the code. + + An earlier version of this took the hash from the client and looked the + request up by it. That is circular: the client cannot compute the hash + without already knowing the keys it is asking about. + """ + roster = self._ctx.get("roster") + if roster is None or not self._user_id: + self._send({"type": "error", "detail": "Roster not available"}) + return + + pending = await roster.list_device_requests(self._user_id) + self._send({ + "type": MNP.DEVICE_LOOKUP_RESULT, "v": MNP_VERSION, + "requests": [ + {"pk_ed25519": r["pk_ed25519"], "pk_x25519": r["pk_x25519"], + "code_hash": r["code_hash"], "created_at": r["created_at"]} + for r in pending + ], + }) + + async def _do_device_add(self, msg: dict) -> None: + """ + Admit a device, countersigned by one this node already pinned. + + The whole control is in `_verify_device_signer`: the signature must + verify against a **live device of this same account**. The hub holds no + user keys and so cannot produce one. + """ + roster = self._ctx.get("roster") + if roster is None or not self._user_id or not self._nonce_node: + self._send({"type": "error", "detail": "Not ready to add a device"}) + return + if not self._spend_device_attempt(): + return + + pk_ed_b64 = str(msg.get("pk_ed25519", "")) + pk_x_b64 = str(msg.get("pk_x25519", "")) + ts = int(msg.get("ts", 0)) + if not (pk_ed_b64 and pk_x_b64): + self._send({"type": "error", "detail": "Missing device keys"}) + return + if abs(time.time() - ts) > DEVICE_TTL: + self._send({"type": "error", "detail": "Approval expired"}) + return + + transcript = device_add_transcript( + node_pk_b64=self._node_pk_b64(), user_id=self._user_id, + pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64, + nonce_node=self._nonce_node, ts=ts) + signer = await self._verify_device_signer(roster, transcript, + msg.get("sig", "")) + if signer is None: + self._audit("device_add_refused", pk_ed_b64[:16]) + self._send({"type": "error", + "detail": "Not signed by a device already paired here"}) + return + + devices = await roster.list_devices(self._user_id) + if len(devices) >= roster.MAX_DEVICES_PER_USER: + self._send({"type": "error", "detail": "Device limit reached"}) + return + + # Spend the request. Single use: an approval cannot be replayed, and a + # code that was used is gone whatever else happens next. + code_hash = str(msg.get("code_hash", "")) + if code_hash and not await roster.take_device_request( + code_hash, self._user_id): + self._send({"type": "error", + "detail": "That request is no longer pending"}) + return + + await roster.pin_identity( + user_id=self._user_id, username=self._username or "", + pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, via="device", + label=str(msg.get("label", ""))[:64], added_by_pk=signer) + self._audit("device_added", f"{pk_ed_b64[:16]} by {signer[:16]}") + log.info("Device added for %s: %s (approved by %s)", + self._user_id[:8], pk_ed_b64[:16], signer[:16]) + self._send({"type": MNP.DEVICE_ADD_ACK, "v": MNP_VERSION, + "pk_ed25519": pk_ed_b64}) + + async def _do_device_list(self, msg: dict) -> None: + """This account's devices. Anyone may read their own, nobody else's.""" + roster = self._ctx.get("roster") + if roster is None or not self._user_id: + self._send({"type": "error", "detail": "Roster not available"}) + return + devices = await roster.list_devices(self._user_id) + pending = await roster.pending_device_requests(self._user_id) + self._send({ + "type": MNP.DEVICE_LIST_RESULT, "v": MNP_VERSION, + "pending": pending, + "devices": [ + {"pk_ed25519": d["pk_ed25519"], "label": d.get("label", ""), + "pinned_at": d["pinned_at"], "pinned_via": d["pinned_via"], + "added_by_pk": d.get("added_by_pk", ""), + "is_this_one": d["pk_ed25519"] == self._pinned_pk} + for d in devices + ], + }) + + async def _do_device_revoke(self, msg: dict) -> None: + """ + Retire one of this account's devices — a lost laptop. + + Countersigned like an addition, by a live device of the same account. + The last one cannot go: an account with no device on this node can only + return through an operator's invitation code, and doing that to yourself + by accident is not a mistake worth allowing. + """ + roster = self._ctx.get("roster") + if roster is None or not self._user_id or not self._nonce_node: + self._send({"type": "error", "detail": "Not ready"}) + return + if not self._spend_device_attempt(): + return + + target = str(msg.get("pk_ed25519", "")) + ts = int(msg.get("ts", 0)) + if not target: + self._send({"type": "error", "detail": "Missing device key"}) + return + if abs(time.time() - ts) > DEVICE_TTL: + self._send({"type": "error", "detail": "Request expired"}) + return + + victim = await roster.find_device(self._user_id, target) + if victim is None: + self._send({"type": "error", "detail": "No such device"}) + return + + transcript = device_add_transcript( + node_pk_b64=self._node_pk_b64(), user_id=self._user_id, + pk_ed25519_b64=target, pk_x25519_b64=victim["pk_x25519"], + nonce_node=self._nonce_node, ts=ts) + signer = await self._verify_device_signer(roster, transcript, + msg.get("sig", "")) + if signer is None: + self._send({"type": "error", + "detail": "Not signed by a device already paired here"}) + return + + if len(await roster.list_devices(self._user_id)) <= 1: + self._send({"type": "error", + "detail": "This is your only device here — removing it " + "would need an operator code to come back"}) + return + + await roster.revoke_device(self._user_id, target) + self._audit("device_revoked", f"{target[:16]} by {signer[:16]}") + log.info("Device revoked for %s: %s", self._user_id[:8], target[:16]) + self._send({"type": MNP.DEVICE_ADD_ACK, "v": MNP_VERSION, + "revoked": target}) + + async def _verify_device_signer(self, roster, transcript: bytes, + sig_b64: str) -> str | None: + """ + The pinned key that signed this, or None. + + Every live device of the account is tried, because any of them may + approve. A revoked one is not in the list — that is the point of marking + rather than deleting: a lost laptop must stop being able to admit its + replacement. + """ + try: + sig = base64.b64decode(sig_b64) + except Exception: + return None + for device in await roster.list_devices(self._user_id): + try: + pk = Ed25519PublicKey.from_public_bytes( + base64.b64decode(device["pk_ed25519"])) + except Exception: + continue + if self._verify_sig(pk, transcript, sig): + return device["pk_ed25519"] + return None + + def _spend_device_attempt(self) -> bool: + """ + Bound guessing on this connection, as the join path does. + + A code is 40 bits, single use and bound to the keys it names, so this is + depth rather than the control — but an unbounded loop over the lookup is + still a free oracle, and a burst of failures belongs in the audit log. + """ + self._device_attempts = getattr(self, "_device_attempts", 0) + 1 + if self._device_attempts > 5: + self._audit("device_attempts_exceeded", str(self._device_attempts)) + self._send({"type": "error", + "detail": "Too many device attempts on this connection"}) + return False + return True + def _group_join_policy(self, group_id: str) -> str: """ Admission policy for a group, read from the node's own configuration. |