summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_transport_wire_parity.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 15:20:40 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 15:20:40 +0200
commitc1be7571973c3d0b671ed4db2da41266ae3099d8 (patch)
treed2b98bf33727c5905669c9c3f40edba30db11ac3 /packages/meshbay-node/tests/test_transport_wire_parity.py
parent691c6ba4ef51085c89aeddbcabd5c733861eb56b (diff)
downloadmeshbay-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.py125
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"]