diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-13 16:29:36 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-13 16:29:36 +0200 |
| commit | 9da3cfc5d31bc4e1b9c4ea70f56e07b9aed8bb85 (patch) | |
| tree | 3a3164422547658b753aece6a10b9ecb47668f6a /packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | |
| parent | 6a3f927413d4fd44b708a306e5e053f6660ec357 (diff) | |
| download | meshbay-9da3cfc5d31bc4e1b9c4ea70f56e07b9aed8bb85.tar.gz | |
fix(node): the node's own controls take no authority from a hub token
`_is_node_admin()` is `self._user_id == node_user_id`, and `_user_id` is the
`sub` of a JWT the hub issued. Six node-wide controls were gated on that alone:
`node_status` — which lists every group on the machine with each root's
**absolute path** — plus `node_settings_set`, `roster_read`, `denylist_read`,
`denylist_clear` and `node_reload`.
So the answer to "are you the operator of this node" was "the hub says so",
which NS4 and M3 rule out in as many words: operator authority comes from the
node's roster and from nowhere else, and asking the hub is how the hub installs
itself as node administrator. The reach is bounded — a completed handshake also
needs the group key — but an active hub obtains one legitimately in an
open-join group, which §3.5 concedes, and from there it could read the
operator's directory layout or clear the denylist, which is the persisted
revocation H4 exists to keep.
`_operator_device()` requires both halves now: the account is the one the node
belongs to, *and* the device on this connection has proved a key the roster
holds as an operator. `device_hello` is signed over a transcript naming the
node, the group and this connection's nonce, and `operator_pks()` is rebuilt
from the roster on each call, so an unpinned browser and a revoked one are both
refused at once. The hub holds no user keys and cannot countersign a device.
Keeping the account check as well is deliberate: dropping it would widen these
node-wide controls to any paired operator of any group on the machine, which is
a separate decision. `_is_node_admin()` stays as what it is in the handshake
ack — a hint telling a client whether to offer the Node page — and says so.
Nothing changes for a paired operator: `device_hello` runs unconditionally
after the ack, and anyone using the Node page's controls is already paired,
since `root_add` and every other signed op has always verified against
`operator_pks()`. A browser that never paired now reads nothing there, which is
the state in which it could already write nothing.
test_node_status.py's fixture set the account and not the device, which is how
it went on passing; it now wires the device the way `device_hello` leaves it.
The adversary itself is in test_security_regressions.py — a token naming the
owner's account with no proved device, which the previous source answered with
`node_status_ack`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 90 |
1 files changed, 71 insertions, 19 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 d36b812..59198d5 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -2656,12 +2656,18 @@ class WebRTCPeerSession: # ── Node management (D5) ───────────────────────────────────────────────── async def _do_node_status(self, msg: dict) -> None: - """All groups, roots, peers — the operator's overview.""" + """All groups, roots, peers — the operator's overview. + + Including every root's absolute path, which is why this is gated on a + proved operator device and not on an account the hub named. + """ node_uid = self._ctx.get("node_user_id") - log.info("node_status: user=%s node_user=%s admin=%s", - self._user_id, node_uid, self._is_node_admin()) - if not self._is_node_admin(): - self._send({"type": "error", "detail": "Not the node operator"}) + log.info("node_status: user=%s node_user=%s owner=%s device=%s", + self._user_id, node_uid, self._is_node_admin(), + "confirmed" if self._device_confirmed else "unidentified") + if not await self._operator_device(): + self._send({"type": "error", "detail": "Not the node operator", + "code": "not_operator"}) return try: result = await self._run_op(ops.list_groups) @@ -2673,8 +2679,9 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "Internal error"}) async def _do_node_settings_set(self, msg: dict) -> None: - if not self._is_node_admin(): - self._send({"type": "error", "detail": "Not the node operator"}) + if not await self._operator_device(): + self._send({"type": "error", "detail": "Not the node operator", + "code": "not_operator"}) return settings = msg.get("settings", {}) if not settings: @@ -2691,8 +2698,9 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "Internal error"}) async def _do_roster_read(self, msg: dict) -> None: - if not self._is_node_admin(): - self._send({"type": "error", "detail": "Not the node operator"}) + if not await self._operator_device(): + self._send({"type": "error", "detail": "Not the node operator", + "code": "not_operator"}) return group_id = str(msg.get("group_id", "")).strip() try: @@ -2705,8 +2713,9 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "Internal error"}) async def _do_denylist_read(self, msg: dict) -> None: - if not self._is_node_admin(): - self._send({"type": "error", "detail": "Not the node operator"}) + if not await self._operator_device(): + self._send({"type": "error", "detail": "Not the node operator", + "code": "not_operator"}) return try: result = await self._run_op(ops.read_denylist) @@ -2718,8 +2727,9 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "Internal error"}) async def _do_denylist_clear(self, msg: dict) -> None: - if not self._is_node_admin(): - self._send({"type": "error", "detail": "Not the node operator"}) + if not await self._operator_device(): + self._send({"type": "error", "detail": "Not the node operator", + "code": "not_operator"}) return subject = str(msg.get("subject", "")).strip() try: @@ -2815,8 +2825,9 @@ class WebRTCPeerSession: log.error("Reload after group_detach failed: %s", e) async def _do_node_reload(self, msg: dict) -> None: - if not self._is_node_admin(): - self._send({"type": "error", "detail": "Not the node operator"}) + if not await self._operator_device(): + self._send({"type": "error", "detail": "Not the node operator", + "code": "not_operator"}) return state = self._ctx.get("daemon_state") reload_fn = state.get("reload_fn") if state else None @@ -5173,15 +5184,56 @@ class WebRTCPeerSession: def _is_node_admin(self) -> bool: """ - Whether the peer on this connection is the node's operator. + Whether the **account** on this connection is the one the node belongs to. - Was written out twice — once in the handshake ack and once at the gate - below it — which is how the two come to disagree. From the node's own - record of who it belongs to, never from a hub claim. + This is a display hint and half of a check — never authority on its own. + `self._user_id` is the `sub` of a JWT the hub issued, so read alone it + says "the hub says you are the owner", which is the one thing NS4 and + M3 rule out: a hub that can name the operator can install itself as + node administrator. It rides the handshake ack so a client knows whether + to offer the Node page at all, and every operation is gated on + `_operator_device()` below. """ node_user_id = self._ctx.get("node_user_id") return bool(node_user_id and self._user_id == node_user_id) + async def _operator_device(self) -> bool: + """ + Whether this connection may run the node's own controls. + + Two things, and the second is the one that cannot be forged: + + - the account is the one this node belongs to (`_is_node_admin`), which + is what keeps node-wide controls with the machine's owner rather than + with every paired operator of every group on it; and + - **the device on this connection proved a key the node pinned as an + operator**. `device_hello` is signed over a transcript naming this + node, this group and this connection's nonce, and `operator_pks()` is + rebuilt from the roster on each call, so an unpinned browser and a + revoked one are both refused at once. + + The second clause is the fix for the door this used to leave open. + `node_status`, `node_settings_set`, `roster_read`, `denylist_read`, + `denylist_clear` and `node_reload` were gated on the account id alone — + a value the hub chooses. An active hub that can also reach the group key + (which §3.5 concedes it can in an open-join group) could therefore mint + a token for the owner's account and read `node_status`, which lists + every group on the node with the operator's **absolute paths**, or clear + the denylist, which is the persisted revocation H4 exists to keep. + + It holds no user keys and cannot countersign anything, so it cannot + produce a `device_hello` — which is the same property device linking + rests on (§3.3), applied to the node's own surface. + """ + if not self._is_node_admin(): + return False + if not self._device_confirmed or not self._pinned_pk: + return False + roster = self._ctx.get("roster") + if roster is None: + return False + return self._pinned_pk in await roster.operator_pks() + def _has_admin_authority(self) -> bool: """ Cheap synchronous pre-check: is there anyone who could authorize this? |