diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-03 15:20:40 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-03 15:20:40 +0200 |
| commit | c1be7571973c3d0b671ed4db2da41266ae3099d8 (patch) | |
| tree | d2b98bf33727c5905669c9c3f40edba30db11ac3 /packages/meshbay-node/tests/test_transport_wire_parity.py | |
| parent | 691c6ba4ef51085c89aeddbcabd5c733861eb56b (diff) | |
| download | meshbay-c1be7571973c3d0b671ed4db2da41266ae3099d8.tar.gz | |
refactor!: one file_chunk and index_sync encoder for every transport
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
Diffstat (limited to 'packages/meshbay-node/tests/test_transport_wire_parity.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_transport_wire_parity.py | 125 |
1 files changed, 125 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_transport_wire_parity.py b/packages/meshbay-node/tests/test_transport_wire_parity.py new file mode 100644 index 0000000..5e04525 --- /dev/null +++ b/packages/meshbay-node/tests/test_transport_wire_parity.py @@ -0,0 +1,125 @@ +""" +The transports must produce the same wire, message for message. + +`file_chunk` and `index_sync` were each built twice — once in `webrtc_server.py`, once +in `quic_server.py` — and the two copies disagreed. WebRTC sent binary, unsigned chunks +carrying a `file_id`; QUIC sent base64 fields, two hashes and a per-chunk Ed25519 +signature, and no `file_id` at all. `index_sync` was plain entries on one transport and +a `GroupIndex.serialize()` envelope on the other. One type, two shapes, and nothing +that failed when they drifted. + +This is the same guard the unified handshake has (`meshbay_common/handshake.py`): the +encoders now live in one place, and these tests fail if a transport grows its own copy +again. +""" + +import inspect + +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.protocol import MNP, file_chunk_plaintext, file_chunk_wire +from meshbay_node.indexer import DirectoryIndexer +from meshbay_node.transport import quic_server, webrtc_server +from meshbay_node.transport.wire import index_sync_message + + +@pytest.fixture +def gek(): + return generate_gek() + + +@pytest.fixture +def shared_dir(tmp_path): + d = tmp_path / "shared" + d.mkdir() + (d / "film.mkv").write_bytes(b"payload " * 500) + (d / "sub").mkdir() + return d + + +def test_both_transports_use_the_one_chunk_encoder(): + """Neither server may encrypt a chunk itself.""" + for module in (webrtc_server, quic_server): + source = inspect.getsource(module) + assert "file_chunk_wire" in source, f"{module.__name__} bypasses the shared encoder" + assert "encrypt_chunk_aes(" not in source, ( + f"{module.__name__} encrypts a chunk on its own — that is how the two " + f"copies diverged the first time") + assert "chunk_key_aes(" not in source, ( + f"{module.__name__} derives a chunk key on its own") + + +def test_both_transports_use_the_one_index_builder(): + for module in (webrtc_server, quic_server): + source = inspect.getsource(module) + assert "index_sync_message" in source, ( + f"{module.__name__} builds index_sync itself") + assert "index_b64" not in inspect.getsource(quic_server), ( + "QUIC is serializing the index again — that was the fork") + + +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" + file_hash = bytes.fromhex("ab" * 32) + + from_webrtc = webrtc_server._read_and_encrypt(gek, path, 0, file_hash, "ab" * 32) + from_quic = quic_server._read_and_encrypt(gek, path, 0, file_hash, "ab" * 32) + + assert from_webrtc.keys() == from_quic.keys() + assert set(from_webrtc) == { + "type", "v", "file_id", "chunk_index", "plaintext_size", "nonce", "ct"} + assert from_webrtc["type"] == from_quic["type"] == MNP.FILE_CHUNK + for field in ("v", "file_id", "chunk_index", "plaintext_size"): + assert from_webrtc[field] == from_quic[field] + + # Binary, not base64 — the 33% Phase 9.15 removed, and the QUIC copy kept. + assert isinstance(from_webrtc["nonce"], bytes) + assert isinstance(from_webrtc["ct"], bytes) + + # A fresh nonce per encryption, so the ciphertexts differ while the plaintext + # both sides recover does not. + assert from_webrtc["ct"] != from_quic["ct"] + plaintext = path.read_bytes() + for msg in (from_webrtc, from_quic): + assert file_chunk_plaintext(gek, msg) == plaintext + + +def test_chunk_round_trip_rejects_a_tampered_ciphertext(gek): + msg = file_chunk_wire(gek, b"the payload", 3, bytes.fromhex("cd" * 32), "cd" * 32) + msg["ct"] = bytes([msg["ct"][0] ^ 1]) + msg["ct"][1:] + with pytest.raises(Exception): + file_chunk_plaintext(gek, msg) + + +def test_chunk_key_is_bound_to_file_and_index(gek): + """A chunk cannot be replayed as another chunk, or as one of another file.""" + file_hash = bytes.fromhex("ef" * 32) + msg = file_chunk_wire(gek, b"the payload", 7, file_hash, "ef" * 32) + + moved = dict(msg, chunk_index=8) + with pytest.raises(Exception): + file_chunk_plaintext(gek, moved) + + with pytest.raises(Exception): + file_chunk_plaintext(gek, msg, file_hash=bytes.fromhex("11" * 32)) + + +@pytest.mark.asyncio +async def test_index_sync_shape(gek, shared_dir): + 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) + + assert msg["type"] == MNP.INDEX_SYNC + assert msg["group_id"] == "g" + assert [e["name"] for e in msg["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"] |