""" 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 — the confusion that treating "the index as chunk 0 of a virtual index file" would create 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