From c1be7571973c3d0b671ed4db2da41266ae3099d8 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 3 Sep 2026 15:20:40 +0200 Subject: refactor!: one file_chunk and index_sync encoder for every transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `file_chunk` and `index_sync` were each built twice, once per transport, and the two copies did not agree. WebRTC sent binary, unsigned chunks carrying a `file_id`; QUIC sent base64 fields, two BLAKE3 hashes, a per-chunk Ed25519 signature and no `file_id`. `index_sync` was plain entries on one transport and a `GroupIndex.serialize()` envelope on the other. One message type, two shapes, one consumer each, and nothing that failed when they drifted — finding C6 one size down, in the two places the handshake unification did not reach. Phase 9.15 moved WebRTC to the binary format and dropped the per-chunk signature; the QUIC encoder was never brought along. It is dropped here rather than reintroduced: the AES-GCM tag authenticates the ciphertext under a GEK-derived key, and since C3 the node authenticates itself once in the handshake instead of once per megabyte. `meshbay_common.protocol` now owns the chunk codec (`chunk_ciphertext`, `file_chunk_wire`, `file_chunk_plaintext`) and `meshbay_node/transport/wire.py` the index builder, which also absorbs the delta the daemon used to hand-build. `test_transport_wire_parity.py` fails if either server grows its own copy back. `ChunkRequest`/`ChunkResponse` are deleted. `ChunkResponse` described the QUIC half while reading like the contract for both, which is what made the fork hard to see at all. BREAKING CHANGE: MNP 0.15 changes the encoding of `file_chunk` and `index_sync` on the QUIC transport. The WebRTC shapes are byte for byte unchanged and no QUIC client ships, which is why this is a MINOR bump; a deployed QUIC peer would have made it MAJOR. Also fixes a test fixture that put a `Path` where the daemon puts a `RootSet`. Nothing caught it: the old QUIC index handler never touched `roots`, and `entry_abs_path` fell through `Path.resolve(strict=...)`, reading the virtual path as a truthy flag and returning the right file by accident. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3 --- .../src/meshbay_node/transport/quic_client.py | 52 ++++++++++------------ 1 file changed, 23 insertions(+), 29 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/transport/quic_client.py') 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 9102085..4debd27 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py @@ -15,7 +15,6 @@ import os import struct from pathlib import Path -import blake3 import jwt import msgpack from aioquic.asyncio import connect, QuicConnectionProtocol @@ -24,9 +23,7 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from meshbay_common import MNP_VERSION -from meshbay_common.crypto import verify_chunk_signature -from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, decrypt_chunk_aes as decrypt_chunk -from meshbay_common.protocol import MNP +from meshbay_common.protocol import MNP, file_chunk_plaintext from meshbay_common.handshake import ( NONCE_LEN, ROLE_CLIENT, @@ -252,15 +249,28 @@ class QuicChunkClient: stream_id = self._proto._quic.get_next_available_stream_id(is_unidirectional=False) return stream_id - async def fetch_index(self) -> bytes: - """Request the Mesh Group Index.""" + async def fetch_index(self) -> dict: + """ + 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. + """ sid = self._new_stream() self._proto._send(sid, {"type": MNP.INDEX_SYNC, "v": MNP_VERSION}) - msg = await self._proto._recv(sid) - return base64.b64decode(msg["index_b64"]) + return await self._proto._recv(sid) async def fetch_chunk(self, file_id: str, chunk_index: int) -> bytes: - """Fetch, verify, and decrypt one chunk over QUIC.""" + """ + Fetch and decrypt one chunk over QUIC. + + The per-chunk Ed25519 signature this used to verify is gone (see + `meshbay_common.protocol`): the AEAD tag authenticates the ciphertext under a + GEK-derived key, and the node proved its identity in the handshake — which + `connect()` verified and pinned — rather than once per megabyte. + """ sid = self._new_stream() self._proto._send(sid, { "type": MNP.FILE_REQUEST, @@ -273,26 +283,10 @@ class QuicChunkClient: if msg.get("type") == "error": raise LookupError(msg.get("detail", "Unknown error")) - ct = base64.b64decode(msg["ct_b64"]) - nonce = base64.b64decode(msg["nonce_b64"]) - ct_hash = base64.b64decode(msg["ct_hash_b64"]) - pt_hash = base64.b64decode(msg["pt_hash_b64"]) - sig = base64.b64decode(msg["sig_b64"]) - file_hash = base64.b64decode(msg["file_hash_b64"]) - ci = msg["chunk_index"] - - verify_chunk_signature(self._pk_node, ci, nonce, ct_hash, sig) - - if blake3.blake3(ct).digest() != ct_hash: - raise ValueError("Ciphertext hash mismatch") - - ckey = derive_chunk_key(self._gek, file_hash, ci) - plaintext = decrypt_chunk(ckey, nonce, ct) - - if blake3.blake3(plaintext).digest() != pt_hash: - raise ValueError("Plaintext hash mismatch after decryption") - - return plaintext + # From the id we asked for, not the one the answer claims: a peer that + # substitutes it would otherwise choose which key we decrypt with. + return file_chunk_plaintext( + self._gek, msg, file_hash=bytes.fromhex(file_id)) async def fetch_stream_segment( self, file_id: str, segment_index: int, segment_duration: int = 4, -- cgit v1.2.3