diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
3 files changed, 91 insertions, 18 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops/members.py b/packages/meshbay-node/src/meshbay_node/ops/members.py index b5da9c0..2562dd6 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/members.py +++ b/packages/meshbay-node/src/meshbay_node/ops/members.py @@ -262,6 +262,52 @@ async def cancel_link_invitation(state: dict, group_id: str, invite_id: str) -> "node": node_cancelled, "hub": hub_cancelled} +async def _after_removal(state: dict, user_id: str, group_ids: list[str]) -> None: + """ + What a removal has to do beyond the roster, whichever door it came through. + + - **A new chat epoch in every group the person could read.** They keep the + key of the epoch they were in; the next one is what they must not get. + - **Every live connection of theirs closed.** The handshake now refuses them + (it consults the roster), so this is what makes the removal immediate + rather than "at their next reconnection". + + It used to live in the MNP handlers only, so a removal from the CLI or the + node page — the doors an operator at their own machine uses — left the + epoch where it was and the person connected. Best effort: a failure here + must not turn a done removal into a refused one, and is logged loudly. + """ + from meshbay_common import MNP_VERSION + from meshbay_common.protocol import MNP + + from meshbay_node.ops.chat import open_chat_epoch + + groups_ctx = state.get("groups_ctx") or {} + for gid in sorted({g for g in group_ids if g}): + try: + result = await open_chat_epoch(state, gid) + except Exception as e: + log.error("Removal of %s: no new chat epoch for group %s (%s) — " + "they still hold the current chat key", user_id[:8], gid[:8], e) + continue + notice = {"type": MNP.CHAT_EPOCH_ACK, "v": MNP_VERSION, "epoch": result["epoch"]} + for session in list((groups_ctx.get(gid) or {}).get("_peers", {}).values()): + try: + session._send(notice) + except Exception: + pass + + webrtc = state.get("webrtc") + if webrtc is not None: + for session in list(getattr(webrtc, "_sessions", {}).values()): + if session._user_id == user_id and ( + not group_ids or session._group_id in group_ids): + try: + await session.close() + except Exception: + pass + + async def revoke_member(state: dict, user_id: str, group_id: str) -> dict: """ Stop serving the group key to someone. @@ -285,6 +331,7 @@ async def revoke_member(state: dict, user_id: str, group_id: str) -> dict: raise OpError("No such member in that group", status=404) log.info("Member revoked: user=%s group=%s member=%s invites_dropped=%d", user_id[:8], group_id[:8], revoked, dropped) + await _after_removal(state, user_id, [group_id] if revoked else []) return {"status": "revoked", "user_id": user_id, "group_id": group_id, "was_member": revoked, "invites_dropped": dropped, # Only what is true: somebody who never redeemed a code never held @@ -297,6 +344,8 @@ async def revoke_member(state: dict, user_id: str, group_id: str) -> dict: async def unpin_member(state: dict, user_id: str) -> dict: """Forget a pinned identity, so the person can pair again with a new key.""" roster = _roster(state) + groups = [m["group_id"] for m in await roster.list_members() + if m.get("user_id") == user_id and m.get("group_id")] if not await roster.unpin(user_id): raise OpError("No such pinned identity", status=404) # Drop the stored keypair bundle too. Left behind, it is served to the next @@ -310,4 +359,5 @@ async def unpin_member(state: dict, user_id: str) -> dict: except Exception: log.warning("unpin: could not drop keypair bundle for %s", user_id[:8]) log.info("Identity unpinned: user=%s", user_id[:8]) + await _after_removal(state, user_id, groups) return {"status": "unpinned", "user_id": user_id} 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, diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py index f701072..7822186 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py @@ -137,7 +137,8 @@ class HandshakeMixin: return {"sig": base64.b64encode(self._ctx["sk_node"].sign(transcript)).decode()} def _do_handshake_response(self, msg: dict) -> None: - if not self._gek_challenge or not hasattr(self, "_pending_sub"): + if not self._gek_challenge or not hasattr(self, "_pending_sub") \ + or getattr(self, "_admitting", False): self._send({"type": "error", "detail": "No pending handshake challenge"}) return @@ -170,8 +171,38 @@ class HandshakeMixin: self._audit_auth_failed(group_id, "GEK HMAC mismatch") return - self._complete_handshake(gek, binding) - self._gek_challenge = None + # One answer at a time: the roster is asked asynchronously, and a + # second response arriving meanwhile must not be taken as a new one. + self._admitting = True + self._spawn(self._admit(gek, binding, group_id)) + + async def _admit(self, gek: bytes, binding: bytes, group_id: str) -> None: + """ + Open the session only for someone this node's roster admits. + + The group-key proof says the peer holds the key, and the token says the + hub counts the account a member. Neither is the node's own answer — + and the roster is supposed to be the authority (§6.1). Without this, a + member revoked here, or unpinned, but still a member on the hub, kept + full access with the key they already held: files, uploads, and — since + chat keys are handed to any session — the very epoch their removal had + just opened. An honest client never met the gap, because it asks for + the key through `join_request`, which does consult the roster; a + client that kept the key did not have to. + """ + try: + roster = self._ctx.get("roster") + if roster is not None and not await roster.is_authorized( + group_id, self._pending_sub): + self._send({"type": "error", + "detail": "This node has not admitted you to this group", + "code": "not_authorized_for_group"}) + self._audit_auth_failed(group_id, "not admitted by the roster") + return + self._complete_handshake(gek, binding) + finally: + self._gek_challenge = None + self._admitting = False def _complete_handshake(self, gek: bytes, binding: bytes) -> None: # Authenticated peers may send large frames (file uploads); unauthenticated |