diff options
Diffstat (limited to 'packages/meshbay-common/tests/test_senderkeys.py')
| -rw-r--r-- | packages/meshbay-common/tests/test_senderkeys.py | 211 |
1 files changed, 211 insertions, 0 deletions
diff --git a/packages/meshbay-common/tests/test_senderkeys.py b/packages/meshbay-common/tests/test_senderkeys.py new file mode 100644 index 0000000..a1181e1 --- /dev/null +++ b/packages/meshbay-common/tests/test_senderkeys.py @@ -0,0 +1,211 @@ +""" +Tests for the Sender Keys group messaging protocol. + +Covers: key creation, distribution, encrypt/decrypt, multi-member groups, +out-of-order delivery, serialization, and key rotation on member removal. +""" + +import pytest + +from meshbay_common.senderkeys import ( + SenderKeyRecord, + SenderKeyDistribution, + SenderKeyMessage, + GroupSenderKeyStore, + encrypt_message, + decrypt_message, +) + + +def test_basic_encrypt_decrypt(): + """Alice encrypts, Bob decrypts using Alice's distributed sender key.""" + alice_rec = SenderKeyRecord.create("alice") + alice_dist = alice_rec.distribution() + + bob_store = GroupSenderKeyStore("group-1") + bob_store.add_sender(alice_dist) + + msg, alice_rec = encrypt_message(alice_rec, b"hello group") + plaintext = decrypt_message(bob_store, msg) + assert plaintext == b"hello group" + + +def test_multiple_messages_sequential(): + """Multiple messages from the same sender decrypt in order.""" + alice_rec = SenderKeyRecord.create("alice") + store = GroupSenderKeyStore("group-1") + store.add_sender(alice_rec.distribution()) + + for i in range(5): + msg, alice_rec = encrypt_message(alice_rec, f"message {i}".encode()) + pt = decrypt_message(store, msg) + assert pt == f"message {i}".encode() + + +def test_multi_member_group(): + """Three members: Alice sends, Bob and Carol both decrypt.""" + alice_rec = SenderKeyRecord.create("alice") + alice_dist = alice_rec.distribution() + + bob_store = GroupSenderKeyStore("group-1") + bob_store.add_sender(alice_dist) + + carol_store = GroupSenderKeyStore("group-1") + carol_store.add_sender(alice_dist) + + msg, alice_rec = encrypt_message(alice_rec, b"broadcast") + + assert decrypt_message(bob_store, msg) == b"broadcast" + assert decrypt_message(carol_store, msg) == b"broadcast" + + +def test_bidirectional_chat(): + """Alice and Bob both send and receive.""" + alice_rec = SenderKeyRecord.create("alice") + bob_rec = SenderKeyRecord.create("bob") + + alice_store = GroupSenderKeyStore("group-1") + alice_store.add_sender(bob_rec.distribution()) + + bob_store = GroupSenderKeyStore("group-1") + bob_store.add_sender(alice_rec.distribution()) + + msg1, alice_rec = encrypt_message(alice_rec, b"hi bob") + assert decrypt_message(bob_store, msg1) == b"hi bob" + + msg2, bob_rec = encrypt_message(bob_rec, b"hi alice") + assert decrypt_message(alice_store, msg2) == b"hi alice" + + +def test_out_of_order_delivery(): + """Messages delivered out of order are decrypted correctly (up to MAX_SKIP).""" + alice_rec = SenderKeyRecord.create("alice") + store = GroupSenderKeyStore("group-1") + store.add_sender(alice_rec.distribution()) + + msg0, alice_rec = encrypt_message(alice_rec, b"msg 0") + msg1, alice_rec = encrypt_message(alice_rec, b"msg 1") + msg2, alice_rec = encrypt_message(alice_rec, b"msg 2") + + # Deliver out of order: 2, 0, 1 + assert decrypt_message(store, msg2) == b"msg 2" + assert decrypt_message(store, msg0) == b"msg 0" + assert decrypt_message(store, msg1) == b"msg 1" + + +def test_replay_rejected(): + """A message decrypted twice raises an error (replay protection).""" + alice_rec = SenderKeyRecord.create("alice") + store = GroupSenderKeyStore("group-1") + store.add_sender(alice_rec.distribution()) + + msg, alice_rec = encrypt_message(alice_rec, b"once only") + decrypt_message(store, msg) + + with pytest.raises(ValueError, match="already consumed"): + decrypt_message(store, msg) + + +def test_unknown_sender_rejected(): + """Message from an unknown sender raises ValueError.""" + alice_rec = SenderKeyRecord.create("alice") + store = GroupSenderKeyStore("group-1") + + msg, _ = encrypt_message(alice_rec, b"who am i") + with pytest.raises(ValueError, match="Unknown sender"): + decrypt_message(store, msg) + + +def test_non_member_cannot_decrypt(): + """Eve (not in group) cannot decrypt Alice's messages.""" + alice_rec = SenderKeyRecord.create("alice") + eve_store = GroupSenderKeyStore("group-1") + + msg, _ = encrypt_message(alice_rec, b"secret") + with pytest.raises(ValueError, match="Unknown sender"): + decrypt_message(eve_store, msg) + + +def test_key_rotation_on_member_removal(): + """After rotation, old chain keys cannot decrypt new messages.""" + alice_rec = SenderKeyRecord.create("alice") + old_dist = alice_rec.distribution() + + # Eve had Alice's old key + eve_store = GroupSenderKeyStore("group-1") + eve_store.add_sender(old_dist) + + # Alice rotates (member removed from group) + alice_rec = alice_rec.rotate() + new_dist = alice_rec.distribution() + + # Bob gets the new distribution + bob_store = GroupSenderKeyStore("group-1") + bob_store.add_sender(new_dist) + + msg, alice_rec = encrypt_message(alice_rec, b"post-rotation") + assert decrypt_message(bob_store, msg) == b"post-rotation" + + # Eve cannot decrypt with old key + with pytest.raises(Exception): + decrypt_message(eve_store, msg) + + +def test_distribution_serialization(): + """SenderKeyDistribution round-trips through serialize/deserialize.""" + rec = SenderKeyRecord.create("alice") + dist = rec.distribution() + data = dist.serialize() + recovered = SenderKeyDistribution.deserialize(data) + + assert recovered.sender_id == dist.sender_id + assert recovered.chain_key == dist.chain_key + assert recovered.iteration == dist.iteration + assert recovered.signing_pk == dist.signing_pk + + +def test_message_serialization(): + """SenderKeyMessage round-trips through serialize/deserialize.""" + rec = SenderKeyRecord.create("alice") + store = GroupSenderKeyStore("group-1") + store.add_sender(rec.distribution()) + + msg, _ = encrypt_message(rec, b"serialize me") + data = msg.serialize() + recovered = SenderKeyMessage.deserialize(data) + + assert recovered.sender_id == msg.sender_id + assert recovered.iteration == msg.iteration + assert recovered.ciphertext == msg.ciphertext + assert recovered.nonce == msg.nonce + assert recovered.signature == msg.signature + + # Deserialized message still decrypts + pt = decrypt_message(store, recovered) + assert pt == b"serialize me" + + +def test_tampered_ciphertext_rejected(): + """Modifying the ciphertext makes signature verification fail.""" + alice_rec = SenderKeyRecord.create("alice") + store = GroupSenderKeyStore("group-1") + store.add_sender(alice_rec.distribution()) + + msg, _ = encrypt_message(alice_rec, b"authentic") + msg.ciphertext = bytes([b ^ 0xff for b in msg.ciphertext]) + + with pytest.raises(Exception): + decrypt_message(store, msg) + + +def test_store_sender_count(): + """GroupSenderKeyStore tracks sender count correctly.""" + store = GroupSenderKeyStore("group-1") + assert store.sender_count == 0 + + store.add_sender(SenderKeyRecord.create("alice").distribution()) + store.add_sender(SenderKeyRecord.create("bob").distribution()) + assert store.sender_count == 2 + + store.remove_sender("alice") + assert store.sender_count == 1 |