Notebook 03 — Ratcheting and key compromise#
We crank the symmetric ratchet through 10 messages and simulate a key compromise. The first half of this notebook shows what would happen with only the symmetric ratchet — the v0.1 behavior. The second half shows how v0.2’s DH ratchet repairs the chain on the next round-trip.
from pqmsg.kdf import derive_chain_step
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
import os
Step 1 — watch the chain advance#
chain_key = os.urandom(32)
for i in range(10):
msg_key, next_ck = derive_chain_step(chain_key)
aead = ChaCha20Poly1305(msg_key)
nonce = os.urandom(12)
ct = aead.encrypt(nonce, f"message {i}".encode(), None)
print(f"msg {i}: msg_key[:4]={msg_key[:4].hex()} chain_key[:4]={next_ck[:4].hex()}")
chain_key = next_ck
msg 0: msg_key[:4]=6c4ef889 chain_key[:4]=65f2473a
msg 1: msg_key[:4]=27496e9c chain_key[:4]=4eb82251
msg 2: msg_key[:4]=7121ea2a chain_key[:4]=47aab892
msg 3: msg_key[:4]=dc804a20 chain_key[:4]=a0e77ba5
msg 4: msg_key[:4]=94bfb5b9 chain_key[:4]=c23162ab
msg 5: msg_key[:4]=5df4584f chain_key[:4]=b83e56d7
msg 6: msg_key[:4]=5fbd9faa chain_key[:4]=e37ff80d
msg 7: msg_key[:4]=b5d9ff0c chain_key[:4]=78da73bb
msg 8: msg_key[:4]=2e6a3370 chain_key[:4]=23bb0bc5
msg 9: msg_key[:4]=3e876976 chain_key[:4]=dd92f573
Step 2 — simulate compromise at step 5#
Suppose an attacker steals chain_key at step 5 (after message 4 has been sent).
chain_key = os.urandom(32)
stolen = None
saved_keys = []
for i in range(10):
if i == 5:
stolen = chain_key
msg_key, next_ck = derive_chain_step(chain_key)
saved_keys.append(msg_key)
chain_key = next_ck
print("stolen chain_key (first 8B):", stolen[:8].hex())
stolen chain_key (first 8B): 43638700b3a6643f
Step 3 — can the attacker decrypt past messages?#
From the stolen chain_key, the attacker tries to derive message keys 0..4.
ck = stolen
attacker_keys_future = []
for i in range(5, 10):
mk, nxt = derive_chain_step(ck)
attacker_keys_future.append(mk)
ck = nxt
print("Future messages (5..9): attacker reconstructs every msg_key.")
for i in range(5, 10):
matches = attacker_keys_future[i - 5] == saved_keys[i]
print(f" msg {i} key reconstructed: {matches}")
print("\nPast messages (0..4): attacker has chain_key at step 5, and HKDF is one-way.")
print(" There is NO function that turns next_chain into previous chain_key.")
print(" Attacker CANNOT compute msg_keys 0..4 from `stolen`.")
Future messages (5..9): attacker reconstructs every msg_key.
msg 5 key reconstructed: True
msg 6 key reconstructed: True
msg 7 key reconstructed: True
msg 8 key reconstructed: True
msg 9 key reconstructed: True
Past messages (0..4): attacker has chain_key at step 5, and HKDF is one-way.
There is NO function that turns next_chain into previous chain_key.
Attacker CANNOT compute msg_keys 0..4 from `stolen`.
Takeaways from the symmetric-only chain#
Forward secrecy holds: messages before the compromise stay confidential because HKDF is one-way.
Backward secrecy fails: every message from the compromise onward is readable.
The next section shows v0.2’s fix: a fresh DH exchange every time the conversation flips direction.
v0.2 — DH ratchet to the rescue#
The full pqmsg.session API uses the Double Ratchet: every time the direction of the conversation flips, the next sender generates a fresh X25519 keypair and re-keys their send chain via HKDF(root_key, DH(new_eph, peer_dh_pub)). The same chain-key compromise as above no longer compromises future messages — once the peer replies, the chain heals.
We replay the same scenario with the real session API.
from pqmsg.identity import generate_identity
from pqmsg.session import initiate_session, accept_session, encrypt, decrypt
from pqmsg.kdf import derive_chain_step
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.exceptions import InvalidTag
alice = generate_identity('alice')
bob = generate_identity('bob')
sess_a, hs = initiate_session(
ours=alice, peer_name='bob',
peer_x25519_pub=bob.x25519_public,
peer_ml_kem_pub=bob.ml_kem_public,
)
sess_b = accept_session(
ours=bob, peer_name='alice',
peer_x25519_pub=alice.x25519_public, handshake=hs,
)
# Five Alice -> Bob messages (one Alice burst)
for i in range(5):
m = encrypt(sess_a, f'a->b {i}'.encode(), is_first=(i == 0), handshake=hs if i == 0 else None)
decrypt(sess_b, m)
# Attacker captures Alice's send chain key at this exact point
leaked = sess_a.chain_key_send
print('leaked chain_key (first 8B):', leaked[:8].hex())
leaked chain_key (first 8B): bcf806a97e9c3955
Now Bob replies, then Alice sends again. Alice’s reply triggers a DH rotation — her new send chain mixes in DH(new_eph, bob_dh_pub), entropy the attacker has not seen.
# Bob -> Alice: direction flip carries Bob's new DH pub
reply = encrypt(sess_b, b'reply', is_first=False, handshake=None)
decrypt(sess_a, reply)
# Alice -> Bob: start of a new burst → DH ratchet rotation on her side
msg6 = encrypt(sess_a, b'healed', is_first=False, handshake=None)
# Naive attacker: advance leaked chain once and try the predicted message key
predicted_key, _ = derive_chain_step(leaked)
aead = ChaCha20Poly1305(predicted_key)
try:
aead.decrypt(msg6['nonce'], msg6['ciphertext'], None)
print('attacker decrypted msg6 — chain NOT healed')
except InvalidTag:
print('attacker rejected — chain HEALED by DH ratchet')
# Bob saw Alice's new dh_pub in the header and decrypts cleanly
print('bob plaintext:', decrypt(sess_b, msg6).decode())
attacker rejected — chain HEALED by DH ratchet
bob plaintext: healed
What v0.2 actually buys#
Post-compromise security: a one-time leak no longer dooms every future message. The chain heals on the next round-trip.
Out-of-order delivery: the receiver caches skipped message keys (bounded), so a dropped or late message no longer wedges the channel. See
tests/test_dh_ratchet.py::test_out_of_order_within_chain_decryptsfor the spec.Wire format: every message now carries
dh_pubandprev_chain_length. This is wire format v2; v1 is rejected. See notebook 07 — Reference.
What v0.2 still does not buy — see notebook 05: authentication beyond TOFU, sealed sender, multi-device, group chats, constant-time crypto. Those remain genuinely out of scope.