aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/tests/test_groupbox.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-common/tests/test_groupbox.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-common/tests/test_groupbox.py')
-rw-r--r--packages/meshbay-common/tests/test_groupbox.py120
1 files changed, 120 insertions, 0 deletions
diff --git a/packages/meshbay-common/tests/test_groupbox.py b/packages/meshbay-common/tests/test_groupbox.py
new file mode 100644
index 0000000..65d8ce6
--- /dev/null
+++ b/packages/meshbay-common/tests/test_groupbox.py
@@ -0,0 +1,120 @@
+"""
+The group-key envelope: round trip, and every refusal it owes.
+
+`groupbox.seal`/`unseal` is what puts `index_sync`, `index_delta` and the
+`handshake_ack` configuration under a key the hub does not hold. The structural
+tests here matter less than `test_index_no_cleartext.py`, which asserts the
+property on a real frame; these pin the primitive.
+"""
+
+import msgpack
+import pytest
+from cryptography.exceptions import InvalidTag
+from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import (
+ PURPOSE_ACK,
+ PURPOSE_INDEX,
+ group_key,
+ seal,
+ unseal,
+)
+
+PAYLOAD = {"version": 7, "entries": [{"name": "a.mkv", "size": 12}], "dirs": ["root"]}
+
+
+@pytest.fixture
+def gek():
+ return generate_gek()
+
+
+def test_round_trip(gek):
+ for purpose in (PURPOSE_INDEX, PURPOSE_ACK):
+ sealed = seal(gek, purpose, "index_sync", "g1", PAYLOAD)
+ assert set(sealed) == {"nonce", "ct"}
+ assert unseal(gek, purpose, "index_sync", "g1", sealed) == PAYLOAD
+
+
+def test_a_wrong_key_does_not_open(gek):
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(generate_gek(), PURPOSE_INDEX, "index_sync", "g1", sealed)
+
+
+def test_purposes_are_separate_key_spaces(gek):
+ """
+ The reason there are two info strings rather than one key reused.
+
+ An ack sealed under the index subkey would otherwise be openable by anything
+ holding the index subkey, which is the confusion `GroupIndex.serialize()`'s
+ "the index as chunk 0 of a virtual index file" creates for chunk keys.
+ """
+ assert group_key(gek, PURPOSE_INDEX) != group_key(gek, PURPOSE_ACK)
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_ACK, "index_sync", "g1", sealed)
+
+
+def test_a_body_cannot_be_replayed_as_another_message_type(gek):
+ """The AAD's first half: an index_sync body is not an index_delta."""
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_INDEX, "index_delta", "g1", sealed)
+
+
+def test_a_body_cannot_be_moved_between_groups(gek):
+ """
+ The AAD's second half. Two groups on one node share a GEK-holding process but
+ not a GEK; this closes the case where they do share one (a rotation in flight,
+ a test fixture, an operator reusing a key) as well.
+ """
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "group-a", PAYLOAD)
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_INDEX, "index_sync", "group-b", sealed)
+
+
+def test_a_tampered_ciphertext_does_not_open(gek):
+ sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+ sealed["ct"] = bytes([sealed["ct"][0] ^ 1]) + sealed["ct"][1:]
+ with pytest.raises(InvalidTag):
+ unseal(gek, PURPOSE_INDEX, "index_sync", "g1", sealed)
+
+
+def test_a_message_that_is_not_sealed_is_refused_as_such(gek):
+ """
+ Not an empty payload, and not a crash on a missing key — the two shapes a
+ caller might otherwise paper over.
+ """
+ with pytest.raises(ValueError):
+ unseal(gek, PURPOSE_INDEX, "index_sync", "g1", {"entries": []})
+
+
+def test_a_fresh_nonce_per_message(gek):
+ """
+ Never derived from the payload: two identical payloads under one long-lived
+ subkey would then reuse a nonce, which for GCM is a total break.
+ """
+ nonces = {seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)["nonce"]
+ for _ in range(50)}
+ assert len(nonces) == 50
+
+
+def test_no_key_is_an_error_not_a_plaintext_fallback(gek):
+ with pytest.raises(ValueError):
+ seal(None, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
+
+
+def test_an_unknown_purpose_is_refused(gek):
+ with pytest.raises(ValueError):
+ group_key(gek, "chat")
+
+
+def test_the_envelope_carries_no_readable_payload(gek):
+ """
+ The property, at the level of the primitive: what `seal` returns holds nothing
+ of what went in. `test_index_no_cleartext.py` asserts the same thing on the
+ real frames.
+ """
+ payload = {"video_root": "holidays-2019-invoices", "entries": ["ledger.pdf"]}
+ frame = msgpack.packb(seal(gek, PURPOSE_ACK, "handshake_ack", "g1", payload))
+ for word in (b"holidays", b"invoices", b"ledger", b"video_root"):
+ assert word not in frame