""" 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