aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/chat/store.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/__init__.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/chat.py19
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py41
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py1
-rw-r--r--packages/meshbay-node/tests/golden/dispatch.json460
-rw-r--r--packages/meshbay-node/tests/test_chat_purge.py110
8 files changed, 604 insertions, 37 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/chat/store.py b/packages/meshbay-node/src/meshbay_node/chat/store.py
index 17cd68e..9e07aff 100644
--- a/packages/meshbay-node/src/meshbay_node/chat/store.py
+++ b/packages/meshbay-node/src/meshbay_node/chat/store.py
@@ -289,6 +289,12 @@ class ChatStore:
await self._db.commit()
return cursor.rowcount
+ async def delete_all(self) -> int:
+ """The operator's purge. Returns how many rows went."""
+ cursor = await self._db.execute("DELETE FROM messages")
+ await self._db.commit()
+ return cursor.rowcount
+
async def message_count(self) -> int:
cursor = await self._db.execute("SELECT COUNT(*) FROM messages")
row = await cursor.fetchone()
diff --git a/packages/meshbay-node/src/meshbay_node/ops/__init__.py b/packages/meshbay-node/src/meshbay_node/ops/__init__.py
index bfdfd7b..fc5b7ef 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/__init__.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/__init__.py
@@ -41,6 +41,7 @@ from meshbay_node.ops.chat import (
ensure_chat_epoch,
open_chat_epoch,
prune_chat,
+ purge_chat,
)
from meshbay_node.ops.core import (
OpError,
@@ -140,6 +141,7 @@ __all__ = [
"pair_operator",
"plug_root",
"prune_chat",
+ "purge_chat",
"prune_index_cache",
"read_denylist",
"read_roster",
diff --git a/packages/meshbay-node/src/meshbay_node/ops/chat.py b/packages/meshbay-node/src/meshbay_node/ops/chat.py
index 539284b..9fc7b94 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/chat.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/chat.py
@@ -252,3 +252,22 @@ async def prune_chat(state: dict, group_id: str, max_age_days: int) -> dict:
group_id[:8], removed)
return {"group_id": group_id, "removed": removed,
"max_age_days": max_age_days}
+
+
+async def purge_chat(state: dict, group_id: str) -> dict:
+ """
+ Delete every stored message of the group. Epoch keys are kept, as in
+ `prune_chat`, and attachments too: they are files in a shared folder.
+
+ The replay index goes with the rows, which is harmless: the node takes a
+ sealed message only from the device that signed it, so the one party able
+ to send a purged message again is its author.
+ """
+ ctx = _group_ctx(state, group_id)
+ store = ctx.get("chat_store")
+ if store is None:
+ raise OpError("This group has no chat store", status=404)
+ removed = await store.delete_all()
+ log.info("Chat purged for group %s: %d message(s) removed",
+ group_id[:8], removed)
+ return {"group_id": group_id, "removed": removed}
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
index 48734ab..7b3b1c9 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
@@ -15,6 +15,7 @@ from meshbay_common.adminop import (
OP_CHAT_DIRECTORY,
OP_CHAT_EPOCH,
OP_CHAT_LINK_PREVIEW,
+ OP_CHAT_PURGE,
OP_DIR_DELETE,
OP_FILE_DELETE,
OP_INVITE_CANCEL,
@@ -68,6 +69,7 @@ _ADMIN_EXECUTORS = {
OP_CHAT_LINK_PREVIEW: "_admin_exec_chat_link_preview",
OP_SEARCH_LISTED: "_admin_exec_search_listed",
OP_CHAT_EPOCH: "_admin_exec_chat_epoch",
+ OP_CHAT_PURGE: "_admin_exec_chat_purge",
OP_ROOT_EJECT: "_admin_exec_root_eject",
OP_ROOT_PLUG: "_admin_exec_root_plug",
}
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
index dc43ae2..f200884 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
@@ -7,7 +7,12 @@ import time
import blake3
from meshbay_common import MNP_VERSION
-from meshbay_common.adminop import OP_CHAT_DIRECTORY, OP_CHAT_EPOCH, OP_CHAT_LINK_PREVIEW
+from meshbay_common.adminop import (
+ OP_CHAT_DIRECTORY,
+ OP_CHAT_EPOCH,
+ OP_CHAT_LINK_PREVIEW,
+ OP_CHAT_PURGE,
+)
from meshbay_common.chatbox import NONCE_LEN as CHAT_NONCE_LEN
from meshbay_common.chatbox import SIG_LEN as CHAT_SIG_LEN
from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, seal
@@ -227,6 +232,40 @@ class ChatMixin:
self._broadcast_to_group({"type": MNP.CHAT_EPOCH_ACK,
"v": MNP_VERSION, "epoch": result["epoch"]})
+ def _do_chat_purge(self, msg: dict) -> None:
+ """
+ Delete the group's stored chat. Operator only, and signed. The subject is
+ the group id, so a signature for one group's purge purges no other.
+
+ Always this connection's group, never one named in the message: the ack
+ is broadcast to this group, and an operator purges the chat they see.
+ """
+ group_id = self._group_id
+ if not group_id:
+ self._send({"type": "error", "detail": "No group on this connection"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(OP_CHAT_PURGE, group_id, group_id=group_id)
+
+ async def _admin_exec_chat_purge(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"chat_purge:{pending['subject'][:8]}")
+ return
+ try:
+ result = await self._run_op(ops.purge_chat, pending["subject"])
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("chat_purge", str(result["removed"]))
+ # Every open chat panel empties, not only the operator's.
+ self._broadcast_to_group({"type": MNP.CHAT_PURGE_ACK,
+ "v": MNP_VERSION, "removed": result["removed"]})
+
async def _do_chat_keys_req(self, msg: dict) -> None:
"""
Hand this member every chat epoch key the group has, sealed.
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
index a8b5767..5d4ed94 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
@@ -56,6 +56,7 @@ _HANDLERS = {
MNP.CHAT_LINK_PREVIEW: ("_do_chat_link_preview", INLINE),
MNP.SEARCH_LISTED: ("_do_search_listed", INLINE),
MNP.CHAT_EPOCH: ("_do_chat_epoch", INLINE),
+ MNP.CHAT_PURGE: ("_do_chat_purge", INLINE),
MNP.CHAT_KEYS_REQ: ("_do_chat_keys_req", SPAWNED),
MNP.GROUP_ROSTER_REQ: ("_do_group_roster_req", SPAWNED),
MNP.MEDIA_META_REQ: ("_do_media_meta_request", SPAWNED),
diff --git a/packages/meshbay-node/tests/golden/dispatch.json b/packages/meshbay-node/tests/golden/dispatch.json
index 44d030a..a39c57d 100644
--- a/packages/meshbay-node/tests/golden/dispatch.json
+++ b/packages/meshbay-node/tests/golden/dispatch.json
@@ -60,6 +60,14 @@
"_admin_exec_chat_link_preview"
]
},
+ "chat_purge": {
+ "audit": [],
+ "log": [],
+ "sent": [],
+ "spawned": [
+ "_admin_exec_chat_purge"
+ ]
+ },
"dir_delete": {
"audit": [],
"log": [],
@@ -2012,7 +2020,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2335,7 +2343,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2354,7 +2362,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2373,7 +2381,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2392,7 +2400,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2662,7 +2670,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2676,7 +2684,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2690,7 +2698,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2704,7 +2712,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2718,7 +2726,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2732,7 +2740,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2746,7 +2754,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2760,7 +2768,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -3817,6 +3825,386 @@
],
"spawned": []
},
+ "chat_purge | challenged | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | challenged | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | challenged | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | challenged | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | fresh | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | fresh | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | fresh | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | fresh | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | member | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | member | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | member | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | member | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | operator | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "gggggggggggggggggggggggggggggggg",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "chat_purge",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "gggggggggggggggggggggggggggggggg",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.1"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | operator | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "gggggggggggggggggggggggggggggggg",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "chat_purge",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "gggggggggggggggggggggggggggggggg",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.1"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | operator | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "gggggggggggggggggggggggggggggggg",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "chat_purge",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "gggggggggggggggggggggggggggggggg",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.1"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | operator | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "gggggggggggggggggggggggggggggggg",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "chat_purge",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "gggggggggggggggggggggggggggggggg",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.1"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | challenged | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | challenged | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | challenged | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | challenged | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | fresh | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | fresh | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | fresh | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | fresh | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | member | bare": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | member | lists": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | member | numbers": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | member | strings": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | operator | bare": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | operator | lists": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | operator | numbers": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | operator | strings": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
"client_diag | challenged | bare": {
"audit": [],
"log": [],
@@ -9771,7 +10159,7 @@
"subject": "link:gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -11870,7 +12258,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -11889,7 +12277,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -11908,7 +12296,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13009,7 +13397,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13024,7 +13412,7 @@
"x"
],
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13037,7 +13425,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13050,7 +13438,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13063,7 +13451,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13078,7 +13466,7 @@
"x"
],
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13091,7 +13479,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13104,7 +13492,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13439,7 +13827,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13458,7 +13846,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13477,7 +13865,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13812,7 +14200,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13831,7 +14219,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13850,7 +14238,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -14185,7 +14573,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -14204,7 +14592,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -14223,7 +14611,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -16850,7 +17238,7 @@
"subject": "{\"language\":null,\"token\":null}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -16893,7 +17281,7 @@
"subject": "{\"language\":\"x\",\"token\":\"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881\"}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
diff --git a/packages/meshbay-node/tests/test_chat_purge.py b/packages/meshbay-node/tests/test_chat_purge.py
new file mode 100644
index 0000000..37bd954
--- /dev/null
+++ b/packages/meshbay-node/tests/test_chat_purge.py
@@ -0,0 +1,110 @@
+"""
+The operator's chat purge (`chat_purge`, MNP 6.1): every stored message of the
+group goes, signed like every operator instruction, and every connected member
+is told so their open chat empties.
+
+Driven through `_do_admin_response`, as in `test_admin_ops_mnp.py`: the node
+rebuilds the transcript from the operation it holds, and skipping that would
+test nothing.
+"""
+
+import base64
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.adminop import OP_CHAT_PURGE, admin_transcript
+from meshbay_node import ops
+from meshbay_node.chat import FORMAT_SEALED_V1, ChatStore
+from meshbay_node.roster import open_roster
+from test_admin_ops_mnp import GROUP, _drain, _last, _session
+
+
+@pytest.fixture
+async def roster(tmp_path):
+ r = await open_roster(tmp_path)
+ yield r
+ await r.close()
+
+
+async def _with_chat(tmp_path, roster, *, operator=True, messages=2):
+ session = await _session(tmp_path, roster, operator=operator)
+ store = ChatStore(tmp_path / "chat.db")
+ await store.open()
+ for i in range(messages):
+ await store.save_message(
+ "grenet", 0, b"ct", format=FORMAT_SEALED_V1, epoch=1,
+ device=b"d" * 32, nonce=bytes([i]) * 12, sig=b"s" * 64)
+ session.state["groups_ctx"][GROUP]["chat_store"] = store
+ # A second member connected to the group, to see the broadcast arrive.
+ other = []
+ session.state["groups_ctx"][GROUP]["_peers"] = {
+ "grenet": session, "bob": type("Peer", (), {"_send": other.append})()}
+ return session, store, other
+
+
+def _sign(session, challenge, sk=None):
+ transcript = admin_transcript(
+ op=OP_CHAT_PURGE, node_pk_b64=session._node_pk_b64(), group_id=GROUP,
+ subject=challenge["subject"], nonce=base64.b64decode(challenge["nonce"]),
+ ts=challenge["ts"])
+ sig = (sk or session.sk_op).sign(transcript)
+ session._do_admin_response({"op_id": challenge["op_id"],
+ "signature": base64.b64encode(sig).decode()})
+
+
+async def test_a_signed_purge_empties_the_chat_and_tells_the_group(tmp_path, roster):
+ session, store, other = await _with_chat(tmp_path, roster)
+
+ session._do_chat_purge({})
+ challenge = _last(session)
+ assert challenge["type"] == "admin_challenge"
+ assert challenge["op"] == OP_CHAT_PURGE and challenge["subject"] == GROUP
+ _sign(session, challenge)
+ await _drain(session)
+
+ assert await store.message_count() == 0
+ assert other[-1]["type"] == "chat_purge_ack" and other[-1]["removed"] == 2
+ assert _last(session)["type"] == "chat_purge_ack"
+ await store.close()
+
+
+async def test_the_purge_names_the_connection_group_and_no_other(tmp_path, roster):
+ """A group named in the message is not the one purged: the ack goes to this
+ connection's group, and so does the operation."""
+ session, store, _ = await _with_chat(tmp_path, roster)
+
+ session._do_chat_purge({"group_id": "h" * 32})
+
+ assert _last(session)["subject"] == GROUP
+ await store.close()
+
+
+async def test_a_signature_from_a_non_operator_key_purges_nothing(tmp_path, roster):
+ session, store, other = await _with_chat(tmp_path, roster)
+
+ session._do_chat_purge({})
+ _sign(session, _last(session), sk=Ed25519PrivateKey.generate())
+ await _drain(session)
+
+ assert _last(session)["type"] == "error"
+ assert await store.message_count() == 2
+ assert other == []
+ await store.close()
+
+
+async def test_no_challenge_without_an_operator(tmp_path, roster):
+ session, store, _ = await _with_chat(tmp_path, roster, operator=False)
+
+ session._do_chat_purge({})
+
+ assert _last(session)["type"] == "error"
+ assert session._admin_ops == {}
+ await store.close()
+
+
+async def test_a_group_without_a_chat_store_says_so(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True)
+
+ with pytest.raises(ops.OpError) as e:
+ await ops.purge_chat(session.state, GROUP)
+ assert e.value.status == 404