summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport
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-node/src/meshbay_node/transport
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-node/src/meshbay_node/transport')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py329
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.