diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 88 |
1 files changed, 87 insertions, 1 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 9d16f82..22e5e15 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -62,6 +62,7 @@ from meshbay_common.adminop import ( OP_MEMBER_REVOKE, OP_GEK_ROTATE, OP_MEMBER_UNPIN, + OP_MEMBER_UPLOAD, admin_transcript, ) from meshbay_common.crypto import pk_to_b64, wrap_gek_aes @@ -469,6 +470,8 @@ class WebRTCPeerSession: self._spawn(self._do_device_list(msg)) elif mtype == MNP.DEVICE_REVOKE: self._spawn(self._do_device_revoke(msg)) + elif mtype == MNP.MEMBER_UPLOAD: + self._do_member_upload(msg) elif mtype == MNP.MEMBER_UNPIN: self._do_member_unpin(msg) elif mtype == MNP.GEK_ROTATE: @@ -687,7 +690,11 @@ class WebRTCPeerSession: "proof": base64.b64encode(node_proof).decode(), "sig": base64.b64encode( self._ctx["sk_node"].sign(node_transcript)).decode(), - "is_node_admin": bool(node_user_id and self._user_id == node_user_id), + "is_node_admin": self._is_node_admin(), + # So the interface knows whether to offer uploading at all. Not a + # permission — the node refuses regardless — but without it the + # only way to discover the answer is to try. + "member_upload": bool(self._group_ctx().get("member_upload", True)), } if node_user_id: ack["node_user_id"] = node_user_id @@ -1568,6 +1575,58 @@ class WebRTCPeerSession: self._send({"type": MNP.MEMBER_UNPIN_ACK, "v": MNP_VERSION, "user_id": user_id}) + def _do_member_upload(self, msg: dict) -> None: + """ + Turn uploading by ordinary members on or off, for this group. + + Signed like every other operator action. The setting decides who may + write to the operator's disk, so a node that took it from an unsigned + message would let any member turn it back on for everyone — the control + would be a suggestion. + """ + if "allowed" not in msg: + self._send({"type": "error", "detail": "Missing allowed"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + # The subject is what the operator is shown before signing, so it has to + # name the outcome rather than the operation. + self._issue_admin_challenge( + OP_MEMBER_UPLOAD, "on" if msg.get("allowed") else "off") + + async def _admin_exec_member_upload( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + allowed = pending["subject"] == "on" + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"member_upload:{pending['subject']}") + return + roster = self._ctx.get("roster") + if roster is None: + self._send({"type": "error", "detail": "No roster on this node"}) + return + await roster.set_member_upload(self._group_id or "", allowed, + set_by=self._user_id) + # Stored *and* applied. The upload path is synchronous and reads this + # dict; leaving it to the next restart would make the panel say one + # thing while the node did another. + self._group_ctx()["member_upload"] = allowed + self._audit("member_upload", pending["subject"]) + + # Everyone already connected is told, rather than finding out by having + # an upload refused. Enforcement does not depend on this reaching them — + # it is the node that refuses — but a button that stays visible until + # the next reconnection is a button people press. + notice = {"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION, + "allowed": allowed} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + async def _run_op(self, fn, *args, **kwargs): """ Call an operation from `meshbay_node.ops` with the daemon's own view. @@ -1995,6 +2054,19 @@ class WebRTCPeerSession: "filename": filename}) return + # The operator can close uploading to everyone but themselves. Enforced + # here rather than by hiding a button: the button is a courtesy to the + # people who are not trying, and this is the part that holds against + # someone who is. `is_node_admin` is computed from the identity this + # node pinned, never from a hub claim. + if not ctx.get("member_upload", True) and not self._is_node_admin(): + self._send({"type": "error", + "detail": "Uploading is turned off for this group", + "code": "member_upload_off", + "filename": filename}) + self._audit("upload_refused", filename[:64]) + return + roots: RootSet | None = ctx.get("roots") upload_root = roots.upload_root if roots else None if upload_root is None: @@ -2189,6 +2261,17 @@ class WebRTCPeerSession: if ident: self._pinned_pk = ident["pk_ed25519"] + def _is_node_admin(self) -> bool: + """ + Whether the peer on this connection is the node's operator. + + 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. + """ + node_user_id = self._ctx.get("node_user_id") + return bool(node_user_id and self._user_id == node_user_id) + def _has_admin_authority(self) -> bool: """ Cheap synchronous pre-check: is there anyone who could authorize this? @@ -2269,6 +2352,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_MEMBER_UNPIN: self._spawn( self._admin_exec_member_unpin(pending, transcript, sig_bytes)) + elif pending["op"] == OP_MEMBER_UPLOAD: + self._spawn( + self._admin_exec_member_upload(pending, transcript, sig_bytes)) else: self._send({"type": "error", "detail": "Unknown admin operation"}) |