From efc93c187dba9027292b51ff0e9caa29349c953e Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 10 Sep 2026 17:30:00 +0200 Subject: refactor(node): QUIC does not relay chat it cannot check The QUIC handler stored `payload` as it arrived and broadcast it: no envelope, no signature check, no `device_hello` to check one against. A message reaching a group's archive that way is a plaintext row in an encrypted history, and it would be indistinguishable from one somebody actually wrote. Removed rather than gated. The transport implements neither the per-device sealing nor the device identification the WebRTC path requires, so refusing here would mean maintaining a second, weaker set of rules for a transport with no client; an unimplemented type is logged and dropped, like every other message this transport does not have. The comment on the peer registry loses its chat fan-out aside for the same reason. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3 --- .../src/meshbay_node/transport/quic_server.py | 66 +++++----------------- 1 file changed, 14 insertions(+), 52 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/transport') diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py index e34153c..7b9d094 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -235,8 +235,14 @@ class _MNPServerProtocol(QuicConnectionProtocol): self._do_index_sync_sync(stream_id) elif mtype == MNP.FILE_REQUEST: self._do_file_request_sync(stream_id, msg) - elif mtype == MNP.CHAT_MESSAGE: - self._do_chat_message_sync(stream_id, msg) + # No chat here. A message is sealed under a per-device subkey and + # signed by the device the *connection* proved it is, and this + # transport implements neither `device_hello` nor the envelope + # checks that go with it. Relaying one unchecked would put a + # plaintext row in the group's archive, which is the one thing chat + # encryption is for; an unimplemented type is logged and dropped + # instead, which is what every other unimplemented message here + # does. elif mtype == MNP.PING: # Liveness is transport-agnostic, and a native client over QUIC # has the same half-open problem a DataChannel does. @@ -389,12 +395,12 @@ class _MNPServerProtocol(QuicConnectionProtocol): task.add_done_callback(self._tasks.discard) def _peer_registry(self) -> dict: - """QUIC peers for THIS connection's group, keyed per group so a message - never crosses into another group on a multi-group node (findings M2b / - H1). Deliberately separate from the WebRTC registry that also lives in - the group context: the two transports' session objects have different - `_send` signatures, and cross-transport chat fan-out is not wired (no - QUIC client ships yet).""" + """QUIC peers for THIS connection's group, keyed per group so nothing a + node pushes can cross into another group on a multi-group node. + + Deliberately separate from the WebRTC registry that also lives in the + group context: the two transports' session objects have different + `_send` signatures, so nothing may iterate both as one set.""" return self._group_ctx().setdefault("_quic_peers", {}) def _do_index_sync_sync(self, stream_id: int) -> None: @@ -421,50 +427,6 @@ class _MNPServerProtocol(QuicConnectionProtocol): ctx["gek"], file_path, chunk_index, file_hash, entry.id) self._send(stream_id, chunk_data) - def _do_chat_message_sync(self, stream_id: int, msg: dict) -> None: - """ - Store a chat message and broadcast it to the rest of THIS group. - - `sender_id` is the authenticated session's, never the wire's — a peer - must not be able to post as someone else (NS6 / finding M2a). The store - and the peer set come from the group context, not a connection-global - one, so a message never crosses into another group on a multi-group node - (findings M2b / H1). The WebRTC path has done both since Phase 11.5. - """ - gctx = self._group_ctx() - payload = msg.get("payload", b"") - if isinstance(payload, str): - payload = payload.encode() - - chat_store = gctx.get("chat_store") - if chat_store: - self._spawn(chat_store.save_message( - sender_id=self._user_id, - iteration=msg.get("iteration", 0), - payload=payload, - thread_id=msg.get("thread_id"), - )) - - broadcast = { - "type": MNP.CHAT_MESSAGE, - "v": MNP_VERSION, - "sender_id": self._user_id, - "iteration": msg.get("iteration", 0), - "payload": msg.get("payload", ""), - "thread_id": msg.get("thread_id"), - "group_id": self._group_id or "", - } - # Per connection, not per account — see the WebRTC path and - # docs/chat-sender-keys.md F7. A person's other devices are recipients. - for proto in list(self._peer_registry().values()): - if proto is not self: - try: - proto._send(0, broadcast) - except Exception: - pass - - self._send(stream_id, {"type": "ack", "v": MNP_VERSION}) - def connection_lost(self, exc) -> None: if self._user_id: self._peer_registry().pop(self._registry_key, None) -- cgit v1.2.3