diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_multi_group.py | 32 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_quic_transport.py | 20 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_transport_wire_parity.py | 125 |
3 files changed, 151 insertions, 26 deletions
diff --git a/packages/meshbay-node/tests/test_multi_group.py b/packages/meshbay-node/tests/test_multi_group.py index 2828d08..3dc4778 100644 --- a/packages/meshbay-node/tests/test_multi_group.py +++ b/packages/meshbay-node/tests/test_multi_group.py @@ -16,7 +16,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives import serialization from meshbay_common.crypto import generate_gek, pk_to_b64 -from meshbay_node.indexer import DirectoryIndexer, GroupIndex +from meshbay_node.indexer import DirectoryIndexer from conftest import one_root from meshbay_node.transport.quic_server import QuicChunkServer from meshbay_node.transport.quic_client import QuicChunkClient @@ -79,9 +79,14 @@ async def multi_group_server(sk_node, sk_hub, gek_a, gek_b, dir_a, dir_b, tmp_pa indexer_b = DirectoryIndexer(roots=one_root(dir_b), group_id="group-b", sk_node=sk_node, gek=gek_b) await indexer_b.initial_scan() + # RootSet, not a bare Path — what the daemon actually puts in a group context. + # This held a Path until 2026-09-03 and nothing noticed: the QUIC index handler + # only called `index.serialize()`, and `entry_abs_path` fell through + # `Path.resolve(strict=...)`, reading the virtual path as a truthy flag and + # returning the right file by accident. groups = { - "group-a": {"gek": gek_a, "roots": dir_a, "index": indexer_a.index}, - "group-b": {"gek": gek_b, "roots": dir_b, "index": indexer_b.index}, + "group-a": {"gek": gek_a, "roots": one_root(dir_a), "index": indexer_a.index}, + "group-b": {"gek": gek_b, "roots": one_root(dir_b), "index": indexer_b.index}, } cert_path = tmp_path / "node.crt" @@ -113,13 +118,12 @@ async def test_user_can_access_own_group( pk_node_b64=pk_to_b64(sk_node.public_key()), group_id="group-a", ) as client: - wire = await client.fetch_index() - recovered = GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek_a) - assert recovered.count == 1 + msg = await client.fetch_index() + assert len(msg["entries"]) == 1 - entry = recovered.entries[0] - assert entry.name == "file_a.txt" - chunk = await client.fetch_chunk(entry.id, chunk_index=0) + entry = msg["entries"][0] + assert entry["name"] == "file_a.txt" + chunk = await client.fetch_chunk(entry["id"], chunk_index=0) assert chunk == b"content from group A " * 100 @@ -155,9 +159,8 @@ async def test_dual_group_user_accesses_both( pk_node_b64=pk_to_b64(sk_node.public_key()), group_id="group-a", ) as client_a: - wire_a = await client_a.fetch_index() - idx_a = GroupIndex.deserialize(wire_a, sk_node=sk_node, gek=gek_a) - assert idx_a.entries[0].name == "file_a.txt" + msg_a = await client_a.fetch_index() + assert msg_a["entries"][0]["name"] == "file_a.txt" async with QuicChunkClient( host="127.0.0.1", port=19200, @@ -165,6 +168,5 @@ async def test_dual_group_user_accesses_both( pk_node_b64=pk_to_b64(sk_node.public_key()), group_id="group-b", ) as client_b: - wire_b = await client_b.fetch_index() - idx_b = GroupIndex.deserialize(wire_b, sk_node=sk_node, gek=gek_b) - assert idx_b.entries[0].name == "file_b.txt" + msg_b = await client_b.fetch_index() + assert msg_b["entries"][0]["name"] == "file_b.txt" diff --git a/packages/meshbay-node/tests/test_quic_transport.py b/packages/meshbay-node/tests/test_quic_transport.py index 5720043..fb28295 100644 --- a/packages/meshbay-node/tests/test_quic_transport.py +++ b/packages/meshbay-node/tests/test_quic_transport.py @@ -13,7 +13,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives import serialization from meshbay_common.crypto import generate_gek, pk_to_b64 -from meshbay_node.indexer import DirectoryIndexer, GroupIndex +from meshbay_node.indexer import DirectoryIndexer from conftest import one_root from meshbay_node.transport.quic_server import QuicChunkServer, Denylist from meshbay_node.transport.quic_client import QuicChunkClient @@ -95,7 +95,7 @@ async def test_quic_chunk_roundtrip(sk_node, sk_hub, gek, shared_dir, tmp_path): @pytest.mark.asyncio async def test_quic_fetch_index(sk_node, sk_hub, gek, shared_dir, tmp_path): - """QUIC index sync returns deserializable GroupIndex.""" + """QUIC index sync returns the same message shape WebRTC sends.""" hub_pk_pem = sk_hub.public_key().public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) @@ -121,9 +121,10 @@ async def test_quic_fetch_index(sk_node, sk_hub, gek, shared_dir, tmp_path): pk_node_b64=pk_to_b64(sk_node.public_key()), group_id="g", ) as client: - wire = await client.fetch_index() - recovered = GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek) - assert recovered.count == 2 + msg = await client.fetch_index() + assert msg["group_id"] == "g" + assert len(msg["entries"]) == 2 + assert "dirs" in msg and "roots" in msg await server.stop() @@ -226,8 +227,7 @@ async def test_quic_session_resumption(sk_node, sk_hub, gek, shared_dir, tmp_pat jwt_token=token, gek=gek, pk_node_b64=pk_b64, group_id="g", ) as client: - wire = await client.fetch_index() - assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2 + assert len((await client.fetch_index())["entries"]) == 2 saved_ticket = client.session_ticket saved_cert = client.peer_cert_der @@ -244,8 +244,7 @@ async def test_quic_session_resumption(sk_node, sk_hub, gek, shared_dir, tmp_pat peer_cert_der=saved_cert, group_id="g", ) as client: - wire = await client.fetch_index() - assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2 + assert len((await client.fetch_index())["entries"]) == 2 await server.stop() @@ -281,8 +280,7 @@ async def test_quic_denylist_blocks_user(sk_node, sk_hub, gek, shared_dir, tmp_p jwt_token=token, gek=gek, pk_node_b64=pk_b64, group_id="g", ) as client: - wire = await client.fetch_index() - assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2 + assert len((await client.fetch_index())["entries"]) == 2 # Add user to denylist denylist.deny_user("user-001") 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"] |