aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md5
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py44
-rw-r--r--packages/meshbay-node/tests/test_revocation_closes_sessions.py53
3 files changed, 87 insertions, 15 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index c443955..8c1f446 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -2161,8 +2161,9 @@ Two verbs on a group, and they are distinct things:
| Reversible from the panel | yes | no |
The client shows the real state, not a blanket one. **Revocation is honoured by
-nodes** and the denylist survives a restart (**H4**); signaling refuses a group that
-is not active.
+nodes** and the denylist survives a restart (**H4**): an account's or a group's
+live sessions are closed when the revocation arrives, not left to run until they
+happen to end. Signaling refuses a group that is not active.
**Revocation has one door, and it broadcasts.** Only an administrator revokes, and
only through `POST /v1/admin/revoke`, which signs the revocation and pushes it to every
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 095bcae..1777bc3 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -601,19 +601,8 @@ class NodeDaemon(EnrichmentMixin):
payload = _jwt.decode(
token, session.hub_pk_pem, algorithms=["EdDSA"],
options={"verify_exp": False})
- target = payload.get("target")
- tid = payload.get("target_id", "")
- if target == "user":
- denylist.deny_user(tid)
- elif target == "group":
- # H4: previously dropped on the floor, so "suspend a
- # group" was a hub-only gesture that no node enforced.
- denylist.deny_group(tid)
- self._drop_group_sessions(tid)
- elif target == "jti":
- denylist.deny_jti(tid)
- else:
- log.warning("Unknown revocation target: %r", target)
+ self._apply_revocation(denylist, payload.get("target"),
+ payload.get("target_id", ""))
except Exception as e:
log.warning("Invalid revocation token: %s", e)
@@ -1522,6 +1511,35 @@ class NodeDaemon(EnrichmentMixin):
except Exception:
pass
+ def _apply_revocation(self, denylist, target, target_id: str) -> None:
+ """
+ What a revocation the hub signed does on this node.
+
+ A revoked account or group is refused from now on, and its live sessions
+ are closed: a denylist entry alone stops the next connection and leaves
+ the current one streaming, downloading and chatting until it happens to
+ disconnect.
+ """
+ if target == "user":
+ denylist.deny_user(target_id)
+ self._drop_user_sessions(target_id)
+ elif target == "group":
+ denylist.deny_group(target_id)
+ self._drop_group_sessions(target_id)
+ elif target == "jti":
+ denylist.deny_jti(target_id)
+ else:
+ log.warning("Unknown revocation target: %r", target)
+
+ def _drop_user_sessions(self, user_id: str) -> None:
+ """Close every live session of a revoked account."""
+ if not self._webrtc or not user_id:
+ return
+ for session in list(self._webrtc._sessions.values()):
+ if getattr(session, "_user_id", None) == user_id:
+ spawn(session.close())
+ log.info("Dropped session for revoked account %s", user_id[:8])
+
def _drop_group_sessions(self, group_id: str) -> None:
"""Close live sessions for a revoked group (H4)."""
if not self._webrtc or not group_id:
diff --git a/packages/meshbay-node/tests/test_revocation_closes_sessions.py b/packages/meshbay-node/tests/test_revocation_closes_sessions.py
new file mode 100644
index 0000000..7c8e79c
--- /dev/null
+++ b/packages/meshbay-node/tests/test_revocation_closes_sessions.py
@@ -0,0 +1,53 @@
+"""
+A revocation the hub signed closes what it revokes, not only what comes next.
+
+The denylist refuses the next connection. A revoked account's live sessions
+were left open — streaming, downloading, chatting — until they happened to end;
+only a group's revocation closed its sessions.
+"""
+
+import asyncio
+
+import pytest
+from meshbay_node.daemon import NodeDaemon
+from meshbay_node.transport.quic_server import Denylist
+
+
+class _Session:
+ def __init__(self, user_id, group_id):
+ self._user_id = user_id
+ self._group_id = group_id
+ self.closed = False
+
+ async def close(self):
+ self.closed = True
+
+
+def _daemon(sessions):
+ d = NodeDaemon.__new__(NodeDaemon)
+ d._webrtc = type("T", (), {"_sessions": sessions})()
+ return d
+
+
+@pytest.mark.asyncio
+async def test_a_revoked_account_is_disconnected(tmp_path):
+ mallory, alice = _Session("mallory", "g1"), _Session("alice", "g1")
+ phone = _Session("mallory", "g2")
+ d = _daemon({"a": mallory, "b": alice, "c": phone})
+ deny = Denylist(path=tmp_path / "deny.json")
+
+ d._apply_revocation(deny, "user", "mallory")
+ await asyncio.sleep(0.05)
+
+ assert mallory.closed and phone.closed, "every session of the account ends"
+ assert not alice.closed
+ assert deny.is_denied("mallory", "")
+
+
+@pytest.mark.asyncio
+async def test_a_revoked_group_is_still_disconnected(tmp_path):
+ a, b = _Session("alice", "g1"), _Session("alice", "g2")
+ d = _daemon({"a": a, "b": b})
+ d._apply_revocation(Denylist(path=tmp_path / "deny.json"), "group", "g1")
+ await asyncio.sleep(0.05)
+ assert a.closed and not b.closed