From 5612dbbac41609b3f84784f57f1de538262db9a9 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 30 Sep 2026 11:57:03 +0200 Subject: fix(node): the roster, not the key alone, decides who gets a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handshake opened a session for anyone holding the group key with a hub token naming the group; the roster was consulted only when wrapping the key in a join. A member revoked or unpinned on the node but still a member on the hub kept a full session with the key they already held — and was handed the chat epoch their removal had just opened, since chat keys go to any session. An honest client never met this (it asks for the key through join_request every time); one that kept the key did not have to. - After the proof, the node asks the roster and refuses with `not_authorized_for_group` unless the account is an active member of the group or the node's operator. - A removal from any door — MNP, the node page, the CLI — now opens a new chat epoch in each group the person could read, broadcasts it, and closes every connection they hold (`ops.members._after_removal`). The CLI and the node page did neither. - Design §5.2, protocol §6.1, §6.3, §14.2. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_node/transport/webrtc/group_ops.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc/group_ops.py') diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/group_ops.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/group_ops.py index 5f5f9ed..3756eac 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/group_ops.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/group_ops.py @@ -113,8 +113,9 @@ class GroupOpsMixin: self._audit("admin_auth_failed", f"member_unpin:{user_id[:8]}") return try: + # The new chat epochs and the closed sessions are the op's own + # (`ops.members._after_removal`), for every door alike. await self._run_op(ops.unpin_member, user_id) - await self._new_chat_epoch(self._group_id or "", "member_unpin") except ops.OpError as e: self._send({"type": "error", "detail": e.message}) return @@ -331,26 +332,17 @@ class GroupOpsMixin: return try: + # The op opens the new chat epoch and closes every connection the + # account holds in this group (`ops.members._after_removal`), for + # the loopback API and the CLI as much as for this door. They keep + # the key they already unwrapped; rotating it is the operator's + # call, and the ack says so. result = await self._run_op( ops.revoke_member, user_id, self._group_id or "") - await self._new_chat_epoch(self._group_id or "", "member_revoke") except ops.OpError as e: self._send({"type": "error", "detail": e.message}) return - # Anyone connected right now keeps the key they already unwrapped; what - # they lose is the next one. Rotating it is the operator's call, and the - # ack says so rather than implying this undid anything already read. - # Every connection that account holds, not "the" one: with device - # linking a person may be connected from several at once, and the - # registry is keyed per connection precisely because it cannot hold - # only one of them. - for peer in self._sessions_of(user_id): - try: - await peer.close() - except Exception: - pass - self._audit("member_revoke", user_id) self._send({ "type": MNP.MEMBER_REVOKE_ACK, "v": MNP_VERSION, -- cgit v1.2.3