From 675beed6ff688733a9598f9d82d41578f48316be Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 3 Sep 2026 16:16:55 +0200 Subject: feat!: MNP 1.0 — seal index and handshake_ack under the group key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `index_sync`, `index_delta` and the `handshake_ack` config payload now travel sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and authenticate before it would trust a decryption. Verify, then decrypt. The ack line is integrity, not confidentiality: the signed handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the DTLS channel alone. The index line is defence in depth against a repeat of C1/C6 — a peer served before the handshake completes now gets ciphertext, not filenames. Nothing against an observer, the hub, or a member; that is the whole claim. `index_progress` stays clear (D3, counters only). Chat is out of scope. Failure is fatal: a payload that does not open ends the session naming the message type — never an empty index or an empty `enabled_apps`, both of which are legitimate states. Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min` on `handshake` and `handshake_challenge`, refused with `version_too_old` / `version_too_new` / `version_unreadable`. The flag day was already being paid for; the next breaking change now costs a refusal message. BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and every node must deploy together; the SPA is served by the hub, so a browser picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY --- packages/meshbay-node/src/meshbay_node/daemon.py | 49 +++---- .../src/meshbay_node/transport/quic_client.py | 40 +++++- .../src/meshbay_node/transport/quic_server.py | 24 +++- .../src/meshbay_node/transport/webrtc_server.py | 45 ++++-- .../src/meshbay_node/transport/wire.py | 54 +++++-- packages/meshbay-node/tests/test_daemon.py | 18 ++- .../meshbay-node/tests/test_index_no_cleartext.py | 159 +++++++++++++++++++++ .../tests/test_transport_wire_parity.py | 63 +++++++- .../meshbay-node/tests/test_webrtc_transport.py | 71 ++++++++- 9 files changed, 462 insertions(+), 61 deletions(-) create mode 100644 packages/meshbay-node/tests/test_index_no_cleartext.py (limited to 'packages/meshbay-node') diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 3a024f0..7341423 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -37,7 +37,7 @@ from pathlib import Path import uvicorn from meshbay_common import MNP_VERSION -from meshbay_common.protocol import MNP, index_entry_wire +from meshbay_common.protocol import MNP from meshbay_node.audit import AuditStore from meshbay_node.bundle_store import BundleStore from meshbay_node.chat.store import ChatStore @@ -58,6 +58,7 @@ from meshbay_node.transport import ( QUIC_AVAILABLE, WEBRTC_AVAILABLE, ) +from meshbay_node.transport.wire import index_delta_message, index_sync_message if QUIC_AVAILABLE: from meshbay_node.transport import QuicChunkServer @@ -1003,6 +1004,13 @@ class NodeDaemon: def _push_index_progress(self, group_id: str, progress) -> None: if not self._webrtc: return + # Deliberately NOT sealed, unlike index_sync/index_delta (decision D3). + # Counters only — never a path, never a filename, see IndexProgress in + # indexer.py — pushed every couple of seconds for the whole length of a + # scan. Sealing it would buy an attacker's rough estimate of a library's + # size and cost a key derivation and a decrypt per push. If a field that + # names anything is ever added here, that trade is void and this message + # joins the other two. msg = { "type": MNP.INDEX_PROGRESS, "v": MNP_VERSION, @@ -1135,36 +1143,25 @@ class NodeDaemon: # 11.5 — Push to connected WebRTC peers in this group if self._webrtc: - if delta is not None: - msg = { - "type": MNP.INDEX_DELTA, - "v": MNP_VERSION, - "group_id": idx.group_id, - "base_version": delta.base_version, - "version": delta.version, - "additions": [index_entry_wire(e) for e in delta.additions], - "deletions": delta.deletions, - "updates": [index_entry_wire(e) for e in delta.updates], - } - else: - msg = { - "type": MNP.INDEX_SYNC, - "v": MNP_VERSION, - "group_id": idx.group_id, - "version": idx.version, - "entries": [index_entry_wire(e) for e in idx.entries], - } - pushed = 0 - for session in list(self._webrtc._sessions.values()): - if session._group_id == group_id: + peers = [s for s in list(self._webrtc._sessions.values()) + if s._group_id == group_id] + # Both messages are sealed under a GEK-derived subkey, so building one + # needs a key. A group without one has no peers to push to either — the + # node refuses every handshake while the GEK is None (NS8) — so this is + # "nobody is listening", not a case to send in clear for. + if peers and idx.gek: + msg = (index_delta_message(idx, delta) if delta is not None + else index_sync_message(idx, indexer.roots)) + pushed = 0 + for session in peers: try: session._send(msg) pushed += 1 except Exception: pass - if pushed: - log.info("Index %s pushed to %d WebRTC peers", - "delta" if delta is not None else "sync", pushed) + if pushed: + log.info("Index %s pushed to %d WebRTC peers", + "delta" if delta is not None else "sync", pushed) # 11.9 — Register file hashes with hub swarm table (public groups only, H7) group_cfg = next( diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py index 4debd27..b22b8df 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py @@ -23,11 +23,14 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from meshbay_common import MNP_VERSION +from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, unseal from meshbay_common.protocol import MNP, file_chunk_plaintext from meshbay_common.handshake import ( + MNP_MIN_SUPPORTED, NONCE_LEN, ROLE_CLIENT, ROLE_NODE, + check_version, handshake_transcript, make_proof, quic_binding, @@ -141,6 +144,8 @@ class QuicChunkClient: # TLS session the server does not re-send its certificate. self._peer_cert_der: bytes | None = peer_cert_der self._session_ticket = session_ticket + # The handshake_ack's sealed payload, once connect() has opened it. + self._node_config: dict = {} async def __aenter__(self): await self.connect() @@ -178,6 +183,10 @@ class QuicChunkClient: self._proto._send(self._ctrl_stream, { "type": MNP.HANDSHAKE, "v": MNP_VERSION, + # The oldest node this build can talk to. Declared in the first + # message so a mismatch is a refusal with a code, not a field that + # turns up missing three messages later (L2). + "v_min": MNP_MIN_SUPPORTED, "token": self._jwt_token, "group_id": self._group_id, "nonce": base64.b64encode(nonce_c).decode(), @@ -186,6 +195,8 @@ class QuicChunkClient: reply = await self._proto._recv(self._ctrl_stream) if reply.get("type") != MNP.HANDSHAKE_CHALLENGE: raise ConnectionError(f"QUIC handshake rejected: {reply}") + # The node's half of the range, checked before we speak to it further. + check_version(reply.get("v", ""), reply.get("v_min", "")) nonce_s = base64.b64decode(reply["nonce"]) @@ -231,6 +242,15 @@ class QuicChunkClient: except Exception as exc: raise ConnectionError(f"Node signature invalid: {exc}") from exc + # Verify, then decrypt — in that order, and it is not incidental. The + # proof and the signature above are what decide whether this peer is worth + # trusting at all; opening the payload first would mean acting on data from + # someone we have not authenticated. Empty on this transport today (D5), + # but it must still open: a payload that does not is a peer we cannot talk + # to, not a node with no configuration. + self._node_config = unseal( + self._gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, self._group_id, ack) + log.debug("QUIC connected to %s:%d", self._host, self._port) @property @@ -253,14 +273,24 @@ class QuicChunkClient: """ Request the Mesh Group Index. - Returns the message itself — `{group_id, version, entries, dirs, roots}` — - which is what the WebRTC client has always received. It used to return the - bytes of a `GroupIndex.serialize()` envelope for the caller to deserialize: - the same message type carrying a different encoding on this transport alone. + Returns `{group_id, version, entries, dirs, roots}` — the sealed payload + opened, with `group_id` from the envelope that carried it. It used to return + the bytes of a `GroupIndex.serialize()` envelope for the caller to + deserialize: the same message type carrying a different encoding on this + transport alone. + + A payload that does not open raises. It is never an empty index — that is + indistinguishable from a group with no files, which is why a fallback here + would be worse than a stop (groupbox.py, §3.4). """ sid = self._new_stream() self._proto._send(sid, {"type": MNP.INDEX_SYNC, "v": MNP_VERSION}) - return await self._proto._recv(sid) + msg = await self._proto._recv(sid) + if msg.get("type") == "error": + raise LookupError(msg.get("detail", "index_sync refused")) + payload = unseal( + self._gek, PURPOSE_INDEX, MNP.INDEX_SYNC, self._group_id, msg) + return {"group_id": msg.get("group_id", self._group_id), **payload} async def fetch_chunk(self, file_id: str, chunk_index: int) -> bytes: """ 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 8a32538..6dde3ff 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -36,17 +36,20 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common import MNP_VERSION from meshbay_node.roots import RootSet, entry_abs_path from meshbay_common.handshake import ( + MNP_MIN_SUPPORTED, NONCE_LEN, ROLE_CLIENT, ROLE_NODE, HandshakeError, authorize_token, + check_version, handshake_transcript, make_proof, quic_binding, verify_proof, ) from meshbay_common.crypto import pk_to_b64 +from meshbay_common.groupbox import PURPOSE_ACK, seal from meshbay_common.protocol import MNP, file_chunk_wire from meshbay_node.indexer import GroupIndex from meshbay_node.transport.wire import index_sync_message @@ -269,6 +272,16 @@ class _MNPServerProtocol(QuicConnectionProtocol): module, bound to the QUIC certificate hash. Finding C6 is closed on this transport. """ + # Same order as WebRTC: the range first, so a peer we cannot speak to is + # told so, rather than served messages it will misread (L2). + try: + check_version(msg.get("v", ""), msg.get("v_min", "")) + except HandshakeError as refusal: + self._send(stream_id, {"type": "error", "detail": str(refusal), + "code": refusal.code}) + self._quic.close() + return + try: peer = authorize_token( msg.get("token", ""), @@ -278,7 +291,8 @@ class _MNPServerProtocol(QuicConnectionProtocol): denylist=self._ctx.get("denylist"), ) except HandshakeError as refusal: - self._send(stream_id, {"type": "error", "detail": str(refusal)}) + self._send(stream_id, {"type": "error", "detail": str(refusal), + "code": refusal.code}) self._quic.close() return @@ -306,6 +320,7 @@ class _MNPServerProtocol(QuicConnectionProtocol): self._send(stream_id, { "type": MNP.HANDSHAKE_CHALLENGE, "v": MNP_VERSION, + "v_min": MNP_MIN_SUPPORTED, "nonce": base64.b64encode(self._gek_challenge).decode(), }) @@ -355,12 +370,19 @@ class _MNPServerProtocol(QuicConnectionProtocol): log.info("QUIC handshake OK — user=%s group=%s", self._user_id[:8], self._group_id[:8]) + # The config payload is empty here — QUIC serves no browser, so none of + # the fields WebRTC carries has a consumer on this transport. It is sealed + # anyway (decision D5): one shape per message on every transport, which is + # the lesson of the two `file_chunk` encoders and the two `index_sync` + # encodings. A field added later then has somewhere to go that is already + # authenticated, instead of arriving in clear beside a sealed one. self._send(stream_id, { "type": MNP.HANDSHAKE_ACK, "v": MNP_VERSION, "node_pk": pk_to_b64(self._ctx["sk_node"].public_key()), "proof": base64.b64encode(node_proof).decode(), "sig": base64.b64encode(self._ctx["sk_node"].sign(transcript)).decode(), + **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, peer.group_id, {}), }) self._gek_challenge = None diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 370a8b4..0154319 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -45,11 +45,13 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import ( from meshbay_common import MNP_VERSION from meshbay_common.handshake import ( + MNP_MIN_SUPPORTED, NONCE_LEN, ROLE_CLIENT, ROLE_NODE, HandshakeError, authorize_token, + check_version, handshake_transcript, make_proof, verify_proof, @@ -87,6 +89,7 @@ from meshbay_common.device import ( device_code_hash, device_request_transcript, ) +from meshbay_common.groupbox import PURPOSE_ACK, seal from meshbay_common.join import ( JOIN_TTL, ROLE_MEMBER, @@ -609,6 +612,15 @@ class WebRTCPeerSession: group_id = msg.get("group_id", "") log.info("WebRTC handshake request: group=%s (peer=%s)", group_id[:8] if group_id else "none", self._peer_id) + # Before the token, and before anything is decided from it: a peer we + # cannot speak to is refused with a code it can act on, rather than + # served messages it will misread as missing fields (L2). + try: + check_version(msg.get("v", ""), msg.get("v_min", "")) + except HandshakeError as refusal: + self._send({"type": "error", "detail": str(refusal), + "code": refusal.code}) + return try: peer = authorize_token( msg.get("token", ""), @@ -655,6 +667,9 @@ class WebRTCPeerSession: self._send({ "type": MNP.HANDSHAKE_CHALLENGE, "v": MNP_VERSION, + # Our half of the range. The client refuses us on this rather than + # discovering the mismatch when a field it expected is not there. + "v_min": MNP_MIN_SUPPORTED, "nonce": base64.b64encode(self._gek_challenge).decode(), # Announced here because a first-time joiner needs it *before* the # ack: join_request signs a transcript naming this node, and someone @@ -729,13 +744,15 @@ class WebRTCPeerSession: gek, ROLE_NODE, self._group_id or "", self._nonce_client, self._gek_challenge or b"", binding) - ack = { - "type": MNP.HANDSHAKE_ACK, - "v": MNP_VERSION, - "node_pk": pk_to_b64(self._ctx["sk_node"].public_key()), - "proof": base64.b64encode(node_proof).decode(), - "sig": base64.b64encode( - self._ctx["sk_node"].sign(node_transcript)).decode(), + # Everything the client needs in order to *authenticate* us stays in clear — + # node_pk, proof and sig are what it checks before it would trust a + # decryption, so they cannot themselves be behind one. The configuration + # below is sealed under a GEK-derived subkey, which gives it an + # authentication tag from a key the hub does not hold. Until MNP 1.0 the + # signed transcript named no ack field at all, so is_node_admin, + # enabled_apps, video_root and the rest were authenticated by the DTLS + # channel and nothing else. + config = { "is_node_admin": self._is_node_admin(), # So the interface knows whether to offer uploading at all. Not a # permission — the node refuses regardless — but without it the @@ -790,10 +807,20 @@ class WebRTCPeerSession: }, } if node_user_id: - ack["node_user_id"] = node_user_id + config["node_user_id"] = node_user_id pk_x_b64 = self._ctx.get("pk_x25519_b64") if pk_x_b64: - ack["node_pk_x25519"] = pk_x_b64 + config["node_pk_x25519"] = pk_x_b64 + + ack = { + "type": MNP.HANDSHAKE_ACK, + "v": MNP_VERSION, + "node_pk": pk_to_b64(self._ctx["sk_node"].public_key()), + "proof": base64.b64encode(node_proof).decode(), + "sig": base64.b64encode( + self._ctx["sk_node"].sign(node_transcript)).decode(), + **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, self._group_id or "", config), + } self._send(ack) self._audit("handshake") diff --git a/packages/meshbay-node/src/meshbay_node/transport/wire.py b/packages/meshbay-node/src/meshbay_node/transport/wire.py index 4e09167..c683204 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/wire.py +++ b/packages/meshbay-node/src/meshbay_node/transport/wire.py @@ -14,12 +14,22 @@ consumer each and nothing asserting they matched. Same failure mode as the two `GroupIndex.serialize()`/`deserialize()` are unchanged and still tested — they remain a correct signed index envelope — but they no longer describe any MNP message. Read -them as an at-rest/interchange format, not as a wire contract. +them as an at-rest/interchange format, not as a wire contract. It is also not a +candidate for reuse below: it compresses with zstd, which no browser can decompress +(`DecompressionStream` offers gzip and deflate only). + +Since MNP 1.0 both messages carry their payload **sealed under a GEK-derived subkey** +(`meshbay_common.groupbox`). Only the routing fields — `type`, `v`, `group_id` — stay +in clear: a receiver must route and version-check before it can decrypt, and +`group_id` is the AAD and selects the key besides. `version`/`base_version` moved +*inside* the payload; there is no reason to act on a version number carried by a +message we have not yet authenticated. """ from __future__ import annotations from meshbay_common import MNP_VERSION +from meshbay_common.groupbox import PURPOSE_INDEX, seal from meshbay_common.protocol import MNP, index_entry_wire from meshbay_node.roots import RootSet @@ -60,17 +70,43 @@ def index_sync_message(index, roots: RootSet | None) -> dict: """ The full `index_sync` message for one group. - `dirs` and `roots` are here because directories are not index entries: without - them a folder someone just created, or one they emptied, does not exist as far as - a client is concerned, and a member cannot tell "the drive is unplugged" from "it - is all still there". + `dirs` and `roots` are in the payload because directories are not index entries: + without them a folder someone just created, or one they emptied, does not exist as + far as a client is concerned, and a member cannot tell "the drive is unplugged" + from "it is all still there". """ - return { - "type": MNP.INDEX_SYNC, - "v": MNP_VERSION, - "group_id": index.group_id, + payload = { "version": index.version, "entries": [index_entry_wire(e) for e in index.entries], "dirs": list_dirs(roots), "roots": roots.describe() if roots else [], } + return { + "type": MNP.INDEX_SYNC, + "v": MNP_VERSION, + "group_id": index.group_id, + **seal(index.gek, PURPOSE_INDEX, MNP.INDEX_SYNC, index.group_id, payload), + } + + +def index_delta_message(index, delta) -> dict: + """ + One `index_delta` — what changed since the last thing this node broadcast. + + Built here rather than inline in the daemon, which is where it lived and which + made it the third place an index message was constructed: precisely the drift + that produced two `index_sync` encodings and two `file_chunk` encodings before it. + """ + payload = { + "base_version": delta.base_version, + "version": delta.version, + "additions": [index_entry_wire(e) for e in delta.additions], + "deletions": list(delta.deletions), + "updates": [index_entry_wire(e) for e in delta.updates], + } + return { + "type": MNP.INDEX_DELTA, + "v": MNP_VERSION, + "group_id": index.group_id, + **seal(index.gek, PURPOSE_INDEX, MNP.INDEX_DELTA, index.group_id, payload), + } diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py index 71aae78..8bd169d 100644 --- a/packages/meshbay-node/tests/test_daemon.py +++ b/packages/meshbay-node/tests/test_daemon.py @@ -17,6 +17,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from unittest.mock import AsyncMock, MagicMock, patch from meshbay_common.crypto import generate_gek +from meshbay_common.groupbox import PURPOSE_INDEX, unseal from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig from conftest import one_root from meshbay_node.daemon import NodeDaemon @@ -263,7 +264,8 @@ async def test_daemon_index_change_pushes_to_peers(tmp_path, shared_dir, gek, hu msg = mock_session._send.call_args[0][0] assert msg["type"] == "index_sync" assert msg["group_id"] == "a" * 32 - assert len(msg["entries"]) == indexer.index.count + payload = unseal(gek, PURPOSE_INDEX, "index_sync", "a" * 32, msg) + assert len(payload["entries"]) == indexer.index.count # Finding H7: this group is private, so its content hashes must NOT be # registered with the hub. The test previously asserted the opposite — @@ -391,7 +393,9 @@ async def test_first_broadcast_is_full_sync_second_is_delta(tmp_path, shared_dir await asyncio.sleep(0.05) first = session._send.call_args_list[0].args[0] assert first["type"] == "index_sync" - assert len(first["entries"]) == indexer.index.count + # Sealed since MNP 1.0 — the entries are inside, not on the envelope. + first_payload = unseal(gek, PURPOSE_INDEX, "index_sync", "a" * 32, first) + assert len(first_payload["entries"]) == indexer.index.count # Nothing actually changed in the index between the two calls, but # _on_index_change does not know or care why it was called — the @@ -401,8 +405,9 @@ async def test_first_broadcast_is_full_sync_second_is_delta(tmp_path, shared_dir await asyncio.sleep(0.05) second = session._send.call_args_list[1].args[0] assert second["type"] == "index_delta" - assert second["additions"] == [] - assert second["deletions"] == [] + second_payload = unseal(gek, PURPOSE_INDEX, "index_delta", "a" * 32, second) + assert second_payload["additions"] == [] + assert second_payload["deletions"] == [] @pytest.mark.asyncio @@ -435,8 +440,9 @@ async def test_delta_reflects_additions_and_deletions(tmp_path, shared_dir, gek) delta_msg = session._send.call_args_list[1].args[0] assert delta_msg["type"] == "index_delta" - assert delta_msg["deletions"] == [removed_id] - assert [a["id"] for a in delta_msg["additions"]] == ["new-file-id"] + payload = unseal(gek, PURPOSE_INDEX, "index_delta", "a" * 32, delta_msg) + assert payload["deletions"] == [removed_id] + assert [a["id"] for a in payload["additions"]] == ["new-file-id"] @pytest.mark.asyncio diff --git a/packages/meshbay-node/tests/test_index_no_cleartext.py b/packages/meshbay-node/tests/test_index_no_cleartext.py new file mode 100644 index 0000000..f510884 --- /dev/null +++ b/packages/meshbay-node/tests/test_index_no_cleartext.py @@ -0,0 +1,159 @@ +""" +The test that asserts the property, rather than the mechanism. + +Worth more than checking that a `ct` field is present: this fails for any future +change that puts a name back in the clear, including one nobody thought of as an +index message. Findings C1 (the node HTTP API served the index and plaintext files +on 0.0.0.0 with no authentication) and C6 (the TCP transport accepted a bare JWT +with no GEK proof) were both "a peer that had not completed the handshake was served +data"; sealed, that bug leaks ciphertext instead of a library's filenames. + +The distinctive strings below are invented and could not occur by chance in msgpack +framing or in a field name. +""" + +import msgpack +import pytest +from conftest import one_root +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.crypto import generate_gek +from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, seal, unseal +from meshbay_common.protocol import MNP +from meshbay_node.indexer import DirectoryIndexer, GroupIndex +from meshbay_node.transport.wire import index_delta_message, index_sync_message + +# A filename and a folder name that appear nowhere else in the tree. +SECRET_FILE = "quixotry-ledger-2019.pdf" +SECRET_DIR = "zarfwidget-archive" +SECRET_ROOT = "/srv/vasculum-private/library" + + +@pytest.fixture +def gek(): + return generate_gek() + + +@pytest.fixture +async def indexer(tmp_path, gek): + shared = tmp_path / "shared" + (shared / SECRET_DIR).mkdir(parents=True) + (shared / SECRET_DIR / SECRET_FILE).write_bytes(b"x" * 64) + roots = one_root(shared, name="library") + idx = DirectoryIndexer( + roots=roots, group_id="g-1", sk_node=Ed25519PrivateKey.generate(), gek=gek) + await idx.initial_scan() + return idx + + +def _assert_absent(frame: bytes, *words: str) -> None: + for word in words: + assert word.encode() not in frame, f"{word!r} travels in the clear" + + +@pytest.mark.asyncio +async def test_index_sync_frame_carries_no_filename(indexer): + frame = msgpack.packb(index_sync_message(indexer.index, indexer.roots), + use_bin_type=True) + # Not only the whole name — a fragment of it would be just as much of a leak. + _assert_absent(frame, SECRET_FILE, "quixotry", SECRET_DIR, "zarfwidget", + "entries", "dirs") + # And the routing fields are still readable, or nothing could be dispatched. + msg = msgpack.unpackb(frame, raw=False) + assert msg["type"] == MNP.INDEX_SYNC + assert msg["group_id"] == "g-1" + assert set(msg) == {"type", "v", "group_id", "nonce", "ct"} + + +@pytest.mark.asyncio +async def test_index_sync_payload_still_says_everything(indexer, gek): + """Sealed, not lost: every field a client reads is inside.""" + msg = index_sync_message(indexer.index, indexer.roots) + payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_SYNC, "g-1", msg) + assert [e["name"] for e in payload["entries"]] == [SECRET_FILE] + assert any(d.endswith(SECRET_DIR) for d in payload["dirs"]) + assert payload["roots"] + # Moved inside deliberately (D4): there is no reason to act on a version + # carried by a message we have not authenticated. + assert payload["version"] == indexer.index.version + assert "version" not in msg + + +@pytest.mark.asyncio +async def test_index_delta_frame_carries_no_filename(indexer, gek): + """ + The delta is where a cleartext path would most easily survive: it used to be + hand-built in the daemon, a third construction site for an index message. + """ + before = GroupIndex._snapshot( + "g-1", indexer.index.sk_node, gek, indexer.index.version - 1, {}) + delta = indexer.index.diff(before) + assert delta.additions + + frame = msgpack.packb(index_delta_message(indexer.index, delta), + use_bin_type=True) + _assert_absent(frame, SECRET_FILE, "quixotry", "additions", "deletions") + + payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_DELTA, "g-1", + msgpack.unpackb(frame, raw=False)) + assert [e["name"] for e in payload["additions"]] == [SECRET_FILE] + assert payload["base_version"] == delta.base_version + + +def test_handshake_ack_frame_carries_no_configuration(gek): + """ + The ack is the line that matters most, and it is an integrity gap as much as a + confidentiality one: the node signs `handshake_transcript(...)`, which names no + ack field, so every value below was authenticated by the DTLS channel alone. + """ + config = { + "is_node_admin": True, + "video_root": SECRET_ROOT, + "enabled_apps": ["files", "videos"], + } + ack = { + "type": MNP.HANDSHAKE_ACK, + "v": "1.0", + "node_pk": "Tk9ERVBL", + "proof": "cHJvb2Y=", + "sig": "c2ln", + **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, "g-1", config), + } + frame = msgpack.packb(ack, use_bin_type=True) + _assert_absent(frame, SECRET_ROOT, "vasculum", "video_root", + "enabled_apps", "is_node_admin") + + # What a client needs in order to authenticate the node is still in clear — + # it verifies those *before* it would trust a decryption. + msg = msgpack.unpackb(frame, raw=False) + assert msg["node_pk"] and msg["proof"] and msg["sig"] + assert unseal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, "g-1", msg) == config + + +def test_index_progress_stays_clear_and_stays_counters(): + """ + Decision D3: `index_progress` is deliberately *not* sealed — counters only, + pushed every couple of seconds for the whole length of a scan, so sealing it + would buy a rough library size and cost a decrypt per push. + + The field list is re-derived from the daemon's own source rather than restated + here, so this fails the day the message grows something that names anything — + `IndexProgress` already carries a `current_dir` the push deliberately omits, + and adding it would be one line. That is the moment the trade above is void. + """ + import ast + import inspect + import textwrap + + from meshbay_node.daemon import NodeDaemon + + source = inspect.getsource(NodeDaemon._push_index_progress) + tree = ast.parse(textwrap.dedent(source)) + dicts = [n for n in ast.walk(tree) if isinstance(n, ast.Dict)] + assert len(dicts) == 1, "more than one message built here — re-read this test" + keys = {k.value for k in dicts[0].keys} + assert keys == {"type", "v", "group_id", + "scanning", "scanned_bytes", "total_bytes"}, ( + f"index_progress now carries {keys} — re-read decision D3 before shipping it") + + assert "seal(" not in source + assert "D3" in source, "the reason it is not sealed must stay next to the code" diff --git a/packages/meshbay-node/tests/test_transport_wire_parity.py b/packages/meshbay-node/tests/test_transport_wire_parity.py index 5e04525..bc6b134 100644 --- a/packages/meshbay-node/tests/test_transport_wire_parity.py +++ b/packages/meshbay-node/tests/test_transport_wire_parity.py @@ -19,6 +19,7 @@ import pytest from conftest import one_root from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common.crypto import generate_gek +from meshbay_common.groupbox import PURPOSE_INDEX, unseal from meshbay_common.protocol import MNP, file_chunk_plaintext, file_chunk_wire from meshbay_node.indexer import DirectoryIndexer from meshbay_node.transport import quic_server, webrtc_server @@ -60,6 +61,37 @@ def test_both_transports_use_the_one_index_builder(): "QUIC is serializing the index again — that was the fork") +def test_neither_transport_seals_by_hand(): + """ + `groupbox` is the only sealer, the same rule `file_chunk_wire` already has. + A server reaching for AESGCM or HKDF directly is a second envelope waiting to + disagree with the first about a nonce length, an info string or an AAD. + """ + for module in (webrtc_server, quic_server): + source = inspect.getsource(module) + assert "seal(" in source, f"{module.__name__} sends an unsealed ack" + assert "AESGCM(" not in source, ( + f"{module.__name__} builds its own AEAD instead of using groupbox") + assert "HKDF(" not in source, ( + f"{module.__name__} derives its own subkey instead of using groupbox") + + +def test_the_daemon_does_not_build_an_index_message_itself(): + """ + The delta was hand-built in `_broadcast_index_change` — the third construction + site for an index message, and the one that would have kept sending cleartext + while the other two were sealed. + """ + from meshbay_node import daemon + + source = inspect.getsource(daemon) + assert "index_delta_message" in source and "index_sync_message" in source + assert '"type": MNP.INDEX_DELTA' not in source, ( + "the daemon builds index_delta by hand again") + assert '"type": MNP.INDEX_SYNC' not in source, ( + "the daemon builds index_sync by hand again") + + def test_chunk_wire_shape_is_identical_across_transports(gek, shared_dir): """The two servers' read-and-encrypt helpers agree on every field but the nonce.""" path = shared_dir / "film.mkv" @@ -116,10 +148,35 @@ async def test_index_sync_shape(gek, shared_dir): msg = index_sync_message(indexer.index, roots) + # In clear: what a receiver needs to route and version-check before it can + # decrypt, and nothing else. assert msg["type"] == MNP.INDEX_SYNC assert msg["group_id"] == "g" - assert [e["name"] for e in msg["entries"]] == ["film.mkv"] + assert set(msg) == {"type", "v", "group_id", "nonce", "ct"} + + payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_SYNC, "g", msg) + assert [e["name"] for e in payload["entries"]] == ["film.mkv"] # Directories are not index entries, so they travel separately — including the # empty one, which no entry's path would have revealed. - assert any(d.endswith("sub") for d in msg["dirs"]) - assert msg["roots"] + assert any(d.endswith("sub") for d in payload["dirs"]) + assert payload["roots"] + + +@pytest.mark.asyncio +async def test_a_wrong_key_raises_rather_than_reporting_an_empty_group(gek, shared_dir): + """ + §3.4, at the level a client would hit it. An index that fails to open must not + become an empty index: "the group has no files" is a legitimate state, so a + fallback there is indistinguishable from the truth — which is exactly what + makes it worse than a stop. + """ + sk_node = Ed25519PrivateKey.generate() + roots = one_root(shared_dir) + indexer = DirectoryIndexer(roots=roots, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + msg = index_sync_message(indexer.index, roots) + with pytest.raises(Exception) as caught: + unseal(generate_gek(), PURPOSE_INDEX, MNP.INDEX_SYNC, "g", msg) + # Assert on the refusal, not on a degraded result. + assert not isinstance(caught.value, dict) diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index a4c7f61..dc74752 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -33,6 +33,7 @@ from meshbay_common.crypto import ( unwrap_gek, unwrap_gek_aes, ) +from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, unseal from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes from meshbay_common.protocol import MNP TEST_GROUP = "g" @@ -358,13 +359,20 @@ async def test_webrtc_datachannel_file_transfer(sk_node, sk_hub, gek, shared_dir ack = await _handshake_with_gek_proof(channel, received, sk_hub, gek, browser_pc=browser_pc) assert ack["type"] == MNP.HANDSHAKE_ACK + # 1b) The ack's configuration is sealed under the group key (MNP 1.0), and the + # signed handshake transcript names no ack field — so this envelope is the only + # thing authenticating `is_node_admin` and the rest. + config = unseal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, TEST_GROUP, ack) + assert "is_node_admin" in config + assert "is_node_admin" not in ack # 2) Request index channel.send(_pack({"type": MNP.INDEX_SYNC, "v": MNP_VERSION})) idx_msg = await asyncio.wait_for(received.get(), timeout=5.0) assert idx_msg["type"] == MNP.INDEX_SYNC - assert "entries" in idx_msg - assert len(idx_msg["entries"]) > 0 + assert "entries" not in idx_msg, "the index travels in the clear" + payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_SYNC, TEST_GROUP, idx_msg) + assert len(payload["entries"]) > 0 # 3) Request file chunk entry = next(e for e in indexer.index.entries if e.name == "test.bin") @@ -441,6 +449,65 @@ async def test_webrtc_invalid_jwt_rejected(sk_node, sk_hub, gek, shared_dir): await transport.close_all() +@pytest.mark.asyncio +async def test_webrtc_old_client_is_refused_with_a_code(sk_node, sk_hub, gek, shared_dir): + """ + A version mismatch must present as a refusal, not as a missing field. + + An 0.x client reaching a 1.0 node would otherwise get a `handshake_ack` with + no `enabled_apps` and apply its documented fallback — show every app — and an + `index_sync` with no `entries` it would read as an empty group. Both are + confident wrong answers. The check runs *before* the token, so it costs + nothing and reports the real reason (L2). + """ + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id=TEST_GROUP, + sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + roots=one_root(shared_dir), index=indexer.index, stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + channel = browser_pc.createDataChannel("mnp") + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + received.put_nowait(_unpack(message)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-old") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + await asyncio.sleep(0.5) + + # A perfectly valid token — the refusal must not depend on it, and must not + # be reported as an authorization problem either. + channel.send(_pack({ + "type": MNP.HANDSHAKE, + "v": "0.15", + "token": _make_jwt(sk_hub, groups=[TEST_GROUP]), + "group_id": TEST_GROUP, + "nonce": base64.b64encode(os.urandom(32)).decode(), + })) + + msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert msg["type"] == "error" + # The client matches on the code; the text may be reworded. + assert msg["code"] == "version_too_old" + assert "0.15" in msg["detail"] + + await browser_pc.close() + await transport.close_all() + + @pytest.mark.asyncio async def test_webrtc_request_before_handshake_rejected(sk_node, sk_hub, gek, shared_dir): """WebRTC DataChannel: request without handshake is rejected.""" -- cgit v1.2.3