diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 05:27:16 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 05:27:16 +0200 |
| commit | cb6e33a2c82a2f61712c3418c156b1ef3e12aaf4 (patch) | |
| tree | d0585c3d5079c8f162295e9eadb6a2ea9198fd61 /packages/meshbay-common/tests | |
| parent | 99e965329cc2c88b667d7ba4733851445f3e2641 (diff) | |
| download | meshbay-cb6e33a2c82a2f61712c3418c156b1ef3e12aaf4.tar.gz | |
feat(common): add Double Ratchet algorithm — 6.1
RatchetState: full Signal-spec Double Ratchet (DH ratchet + symmetric
ratchet). KDF_RK/KDF_CK via HKDF-SHA256. AES-256-GCM message encryption.
MKSKIP for out-of-order delivery (max 1000 skipped keys).
ChatMessage dataclass with to_dict/from_dict for wire serialisation.
Properties validated by tests:
✓ Forward secrecy (consumed keys unreplayable)
✓ Out-of-order delivery
✓ Associated data binding
✓ Break-in recovery (post-ratchet keys independent)
✓ 100-message stress test
11/11 tests in 0.06s.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-common/tests')
| -rw-r--r-- | packages/meshbay-common/tests/test_ratchet.py | 167 |
1 files changed, 167 insertions, 0 deletions
diff --git a/packages/meshbay-common/tests/test_ratchet.py b/packages/meshbay-common/tests/test_ratchet.py new file mode 100644 index 0000000..66d2039 --- /dev/null +++ b/packages/meshbay-common/tests/test_ratchet.py @@ -0,0 +1,167 @@ +"""Tests for the Double Ratchet implementation.""" + +import os +import pytest +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + +from meshbay_common.ratchet import ( + RatchetState, + MessageHeader, + ChatMessage, + encrypt_chat_message, + decrypt_chat_message, +) + + +@pytest.fixture +def shared_secret(): + return os.urandom(32) + + +@pytest.fixture +def bob_key(): + return X25519PrivateKey.generate() + + +@pytest.fixture +def alice_bob(shared_secret, bob_key): + from cryptography.hazmat.primitives import serialization + bob_pub_raw = bob_key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + alice = RatchetState.init_sender(shared_secret, bob_pub_raw) + bob = RatchetState.init_receiver(shared_secret, bob_key) + return alice, bob + + +def test_basic_send_receive(alice_bob): + alice, bob = alice_bob + header, ct = alice.encrypt(b"Hello Bob!") + plaintext = bob.decrypt(header, ct) + assert plaintext == b"Hello Bob!" + + +def test_multiple_messages(alice_bob): + alice, bob = alice_bob + messages = [b"msg 1", b"msg 2", b"msg 3", b"msg 4", b"msg 5"] + ciphertexts = [alice.encrypt(m) for m in messages] + for i, (h, ct) in enumerate(ciphertexts): + assert bob.decrypt(h, ct) == messages[i] + + +def test_bidirectional(alice_bob): + """Both sides can send and receive.""" + alice, bob = alice_bob + + # Alice → Bob + h, ct = alice.encrypt(b"Hi from Alice") + assert bob.decrypt(h, ct) == b"Hi from Alice" + + # Bob → Alice (triggers DH ratchet on Alice) + h, ct = bob.encrypt(b"Hi from Bob") + assert alice.decrypt(h, ct) == b"Hi from Bob" + + # Alice → Bob again (new chain) + h, ct = alice.encrypt(b"Second message from Alice") + assert bob.decrypt(h, ct) == b"Second message from Alice" + + +def test_forward_secrecy(alice_bob): + """Encrypting msg N+1 erases the key for msg N.""" + alice, bob = alice_bob + h1, ct1 = alice.encrypt(b"message 1") + h2, ct2 = alice.encrypt(b"message 2") + + assert bob.decrypt(h1, ct1) == b"message 1" + assert bob.decrypt(h2, ct2) == b"message 2" + + # Keys are consumed — cannot replay + with pytest.raises(Exception): + bob.decrypt(h1, ct1) + + +def test_out_of_order_delivery(alice_bob): + """Messages arriving out of order should still decrypt correctly.""" + alice, bob = alice_bob + h1, ct1 = alice.encrypt(b"first") + h2, ct2 = alice.encrypt(b"second") + h3, ct3 = alice.encrypt(b"third") + + # Deliver in reverse order + assert bob.decrypt(h3, ct3) == b"third" + assert bob.decrypt(h2, ct2) == b"second" + assert bob.decrypt(h1, ct1) == b"first" + + +def test_associated_data(alice_bob): + alice, bob = alice_bob + ad = b"sender:alice;group:test-group" + h, ct = alice.encrypt(b"secret", associated_data=ad) + assert bob.decrypt(h, ct, associated_data=ad) == b"secret" + + # Wrong AD fails authentication + with pytest.raises(Exception): + bob.decrypt(h, ct, associated_data=b"wrong-ad") + + +def test_header_encode_decode(): + from cryptography.hazmat.primitives import serialization + sk = X25519PrivateKey.generate() + pub = sk.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + h = MessageHeader(dh_pub=pub, prev_chain_n=5, msg_num=12) + decoded = MessageHeader.decode(h.encode()) + assert decoded.dh_pub == pub + assert decoded.prev_chain_n == 5 + assert decoded.msg_num == 12 + + +def test_many_messages_stress(alice_bob): + """100 messages without DH ratchet — verifies chain key stability.""" + alice, bob = alice_bob + for i in range(100): + h, ct = alice.encrypt(f"message {i}".encode()) + assert bob.decrypt(h, ct) == f"message {i}".encode() + + +def test_encrypt_decrypt_chat_message(alice_bob, shared_secret, bob_key): + alice, bob = alice_bob + msg = encrypt_chat_message(alice, "alice_user_id", "Hello group!") + assert msg.sender_id == "alice_user_id" + assert msg.msg_id != "" + + plaintext = decrypt_chat_message(bob, msg) + assert plaintext == b"Hello group!" + + +def test_chat_message_serialization(alice_bob): + alice, bob = alice_bob + msg = encrypt_chat_message(alice, "alice", "Serialize me") + d = msg.to_dict() + restored = ChatMessage.from_dict(d) + assert decrypt_chat_message(bob, restored) == b"Serialize me" + + +def test_break_in_recovery(shared_secret, bob_key): + """ + Compromise of state at message N does not reveal keys for messages > N. + After a DH ratchet step, new keys are independent of the compromised state. + """ + from cryptography.hazmat.primitives import serialization + bob_pub = bob_key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + + alice = RatchetState.init_sender(shared_secret, bob_pub) + bob = RatchetState.init_receiver(shared_secret, bob_key) + + # Exchange some messages + h, ct = alice.encrypt(b"pre-compromise msg") + bob.decrypt(h, ct) + + # Bob replies (triggers DH ratchet — new keys independent of above) + h, ct = bob.encrypt(b"bob reply triggers ratchet") + alice.decrypt(h, ct) + + # Now Alice's state has fresh keys + h, ct = alice.encrypt(b"post-ratchet msg") + plaintext = bob.decrypt(h, ct) + assert plaintext == b"post-ratchet msg" |