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#

  1. Forward secrecy holds: messages before the compromise stay confidential because HKDF is one-way.

  2. 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_decrypts for the spec.

  • Wire format: every message now carries dh_pub and prev_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.