aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_transport_wire_parity.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
commit675beed6ff688733a9598f9d82d41578f48316be (patch)
tree78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-node/tests/test_transport_wire_parity.py
parent15087b0e8fdb872602310119f14680aaa443fd93 (diff)
downloadmeshbay-675beed6ff688733a9598f9d82d41578f48316be.tar.gz
feat!: MNP 1.0 — seal index and handshake_ack under the group key
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
Diffstat (limited to 'packages/meshbay-node/tests/test_transport_wire_parity.py')
-rw-r--r--packages/meshbay-node/tests/test_transport_wire_parity.py63
1 files changed, 60 insertions, 3 deletions
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)