aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
-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?