aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-13 16:29:36 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-13 16:29:36 +0200
commit9da3cfc5d31bc4e1b9c4ea70f56e07b9aed8bb85 (patch)
tree3a3164422547658b753aece6a10b9ecb47668f6a /packages/meshbay-node/src
parent6a3f927413d4fd44b708a306e5e053f6660ec357 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py90
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?