diff options
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 70 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_roster_pairing.py | 109 |
2 files changed, 179 insertions, 0 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 64df7ac..81db0e9 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -56,6 +56,7 @@ from meshbay_common.handshake import ( ) from meshbay_common.adminop import ( ADMIN_CHALLENGE_TTL, + OP_DIR_DELETE, OP_FILE_DELETE, OP_INVITE_CREATE, admin_transcript, @@ -357,6 +358,8 @@ class WebRTCPeerSession: self._do_file_upload(msg) elif mtype == MNP.DIR_CREATE: self._do_dir_create(msg) + elif mtype == MNP.DIR_DELETE: + self._do_dir_delete(msg) elif mtype == MNP.FILE_DELETE: self._do_file_delete(msg) elif mtype == MNP.ADMIN_RESPONSE: @@ -932,6 +935,70 @@ class WebRTCPeerSession: "dir": str(target.relative_to(shared_root)), }) + def _do_dir_delete(self, msg: dict) -> None: + """ + Remove an empty directory, for the node operator. + + Creating one is not privileged — a member who can add a file may organise + where it goes — but removing one is: it acts on a name other members are + using, and on the operator's disk. Empty is the whole safety property + here. Nothing recursive: refusing a directory with anything in it means + this can never destroy content, whatever the caller intended, so the + operator deletes the files first and sees what they are losing. + """ + ctx = self._group_ctx() + shared_root = ctx.get("shared_root") + if not shared_root: + self._send({"type": "error", "detail": "No shared directory"}) + return + + target = safe_subdir(shared_root, msg.get("dir") or "") + if target is None or target == shared_root: + self._send({"type": "error", "detail": "Invalid directory"}) + return + if not target.is_dir(): + self._send({"type": "error", "detail": "Not a directory"}) + return + if any(target.iterdir()): + self._send({"type": "error", "detail": "Directory is not empty"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for deletion"}) + return + + self._issue_admin_challenge( + OP_DIR_DELETE, str(target.relative_to(shared_root))) + + async def _admin_exec_dir_delete( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + rel = pending["subject"] + ctx = self._group_ctx() + shared_root = ctx.get("shared_root") + target = safe_subdir(shared_root, rel) if shared_root else None + if target is None or target == shared_root or not target.is_dir(): + self._send({"type": "error", "detail": "Not a directory"}) + return + + # Operator only. A file has an uploader who may remove their own; a + # directory has none, so there is no second key to accept here. + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"dir_delete:{rel}") + return + + # Checked again after the signature: the emptiness test that let this + # through happened before a round trip to the operator's browser, and a + # file could have landed in the meantime. + if any(target.iterdir()): + self._send({"type": "error", "detail": "Directory is not empty"}) + return + + target.rmdir() + log.info("Directory removed by %s: %s", self._user_id[:8], rel) + self._audit("dir_delete", rel) + self._send({"type": MNP.DIR_DELETE_ACK, "v": MNP_VERSION, "dir": rel}) + async def _do_keypair_bundle_delete(self) -> None: """ Withdraw our own key backup from this node. @@ -1472,6 +1539,9 @@ class WebRTCPeerSession: if pending["op"] == OP_FILE_DELETE: asyncio.ensure_future( self._admin_exec_file_delete(pending, transcript, sig_bytes)) + elif pending["op"] == OP_DIR_DELETE: + asyncio.ensure_future( + self._admin_exec_dir_delete(pending, transcript, sig_bytes)) elif pending["op"] == OP_INVITE_CREATE: asyncio.ensure_future( self._admin_exec_invite_create(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index a5a48e4..88e426d 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -805,3 +805,112 @@ async def test_each_group_gets_its_own_key(tmp_path, roster): assert await roster.is_authorized("group-a", "member") is True assert await roster.is_authorized("group-b", "member") is False, ( "membership of one group must not admit anyone to another") + + +# ── Removing a directory ───────────────────────────────────────────────────── + +async def _dir_session(tmp_path, roster): + """A session with a shared root and an operator paired, ready for admin ops.""" + session = _session(tmp_path, roster, group_id="g1", gek=generate_gek()) + session._admin_ops = {} + session._ctx["has_admin_authority"] = True + return session + + +async def test_a_directory_with_anything_in_it_is_refused(tmp_path, roster): + session = await _dir_session(tmp_path, roster) + full = tmp_path / "shared" / "full" + full.mkdir() + (full / "keep.txt").write_text("still here") + + session._do_dir_delete({"dir": "full"}) + + assert _last(session).get("detail") == "Directory is not empty" + assert full.exists() and (full / "keep.txt").exists() + + +async def test_no_challenge_is_issued_without_an_operator(tmp_path, roster): + """Fails closed, and says so, rather than asking for a signature nobody can give.""" + session = await _dir_session(tmp_path, roster) + session._ctx["has_admin_authority"] = False + (tmp_path / "shared" / "empty").mkdir() + + session._do_dir_delete({"dir": "empty"}) + + assert _last(session).get("detail") == "No authorized key for deletion" + assert (tmp_path / "shared" / "empty").exists() + + +async def test_the_shared_root_itself_is_not_a_target(tmp_path, roster): + session = await _dir_session(tmp_path, roster) + for attempt in ("", ".", "/", "../shared"): + session._do_dir_delete({"dir": attempt}) + assert _last(session).get("type") == "error", f"{attempt!r} was accepted" + assert (tmp_path / "shared").is_dir() + + +async def test_escaping_the_shared_root_is_refused(tmp_path, roster): + session = await _dir_session(tmp_path, roster) + outside = tmp_path / "outside" + outside.mkdir() + + for attempt in ("../outside", "../../outside", "sub/../../outside"): + session._do_dir_delete({"dir": attempt}) + assert _last(session).get("type") == "error", f"{attempt!r} was accepted" + assert outside.is_dir(), "a path leaving the shared root removed a directory" + + +async def test_an_empty_directory_needs_a_signature_and_then_goes(tmp_path, roster): + """The whole round trip: challenge, operator signature, removal.""" + from meshbay_common.adminop import admin_transcript + + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli") + + session = await _dir_session(tmp_path, roster) + (tmp_path / "shared" / "gone").mkdir() + + session._do_dir_delete({"dir": "gone"}) + challenge = _last(session) + assert challenge["type"] == "admin_challenge" + assert challenge["op"] == "dir_delete" + assert challenge["subject"] == "gone" + + transcript = admin_transcript( + op="dir_delete", node_pk_b64=session._node_pk_b64(), group_id="g1", + subject="gone", nonce=base64.b64decode(challenge["nonce"]), + ts=challenge["ts"]) + await session._admin_exec_dir_delete( + session._admin_ops.pop(challenge["op_id"]) if session._admin_ops + else {"op": "dir_delete", "subject": "gone"}, + transcript, sk_ed.sign(transcript)) + + assert _last(session)["type"] == "dir_delete_ack" + assert not (tmp_path / "shared" / "gone").exists() + + +async def test_someone_elses_signature_does_not_remove_it(tmp_path, roster): + from meshbay_common.adminop import admin_transcript + + sk_op, pk_op, pk_x = _keypair() + await roster.pin_identity("grenet", "grenet", pk_op, pk_x, "code") + await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli") + + sk_member, pk_member, pk_x_m = _keypair() + await roster.pin_identity("mallory", "mallory", pk_member, pk_x_m, "code") + await roster.set_member("g1", "mallory", ROLE_MEMBER, "active", "grenet") + + session = await _dir_session(tmp_path, roster) + (tmp_path / "shared" / "theirs").mkdir() + + transcript = admin_transcript( + op="dir_delete", node_pk_b64=session._node_pk_b64(), group_id="g1", + subject="theirs", nonce=b"\x22" * 32, ts=int(time.time())) + await session._admin_exec_dir_delete( + {"op": "dir_delete", "subject": "theirs"}, + transcript, sk_member.sign(transcript)) + + assert _last(session).get("detail") == "Signature verification failed" + assert (tmp_path / "shared" / "theirs").is_dir(), ( + "a member's signature removed a directory — only the operator may") |