aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ops
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 11:57:03 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 11:57:03 +0200
commit5612dbbac41609b3f84784f57f1de538262db9a9 (patch)
tree571e0bbd18c31be32ca33db35ca42f8aff672cc9 /packages/meshbay-node/src/meshbay_node/ops
parentd3ad243c4ae3a273f623bd5fc631e3266aa4d0e4 (diff)
downloadmeshbay-5612dbbac41609b3f84784f57f1de538262db9a9.tar.gz
fix(node): the roster, not the key alone, decides who gets a session
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/members.py50
1 files changed, 50 insertions, 0 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}