Double Ratchet
Per-message forward secrecy. Break-in recovery. Key deletion by design.
Overview
The Double Ratchet algorithm, designed by Trevor Perrin and Moxie Marlinspike, combines two ratcheting mechanisms to provide continuously evolving encryption keys. After the initial key exchange (X3DH) establishes a shared secret, the Double Ratchet ensures that:
- Every message is encrypted with a unique, single-use key
- Compromise of a message key reveals only that single message
- Compromise of session state is healed after the next DH ratchet step
- Past messages cannot be decrypted even if current keys are compromised
RVNT's implementation follows the Double Ratchet specification with the addition of header encryption, which prevents an observer from learning the ratchet state (message numbers, DH public keys) even if they can see the ciphertext.
The Two Ratchets
Symmetric Ratchet (KDF Chain)
The symmetric ratchet is a Key Derivation Function (KDF) chain. Each step takes the current chain key and produces two outputs: a new chain key and a message key. The chain key advances. The message key is used once and deleted.
Chain Key [n] ──────────────────── Chain Key [n+1]
│ │
│ HMAC-SHA256(ck, 0x01) │ HMAC-SHA256(ck, 0x01)
▼ ▼
Message Key [n] Message Key [n+1]
│ │
│ encrypt msg, then delete │ encrypt msg, then delete
▼ ▼
(gone) (gone) This is a one-way process. Given chain key [n+1], you cannot derive chain key [n] or message key [n]. This is what provides forward secrecy within a single chain: even if an attacker obtains the current chain key, they cannot recover past message keys.
DH Ratchet (Diffie-Hellman Ratchet)
The DH ratchet advances the root key by performing a new Diffie-Hellman key exchange. This happens when the conversation direction changes (i.e., the other party sends a reply). Each party generates a fresh X25519 keypair for each DH ratchet step.
Root Key [n] + DH(my_new_key, their_key)
│
│ HKDF
▼
Root Key [n+1] + new Sending Chain Key + new Header Key
The old root key is deleted.
The old DH private key is deleted.
The old sending chain is replaced entirely. The DH ratchet provides break-in recovery: if an attacker compromises the current session state, the next DH ratchet step generates entirely new key material that the attacker cannot derive (because they do not know the new DH private key).
Combined Operation
Root Chain Sending Chain Receiving Chain
═════════ ══════════════ ════════════════
RK[0] ─── DH ──→ RK[1]
└──→ SCK[0] ──→ SCK[1] ──→ SCK[2]
│ │ │
MK[0] MK[1] MK[2] (send 3 messages)
Receive reply → DH ratchet step:
RK[1] ─── DH ──→ RK[2]
└──→ RCK[0] ──→ RCK[1] (receive 2 messages)
│ │
MK[0] MK[1]
Send again → DH ratchet step:
RK[2] ─── DH ──→ RK[3]
└──→ SCK'[0] ──→ SCK'[1] (send 2 more messages)
│ │
MK[0] MK[1]
Each DH ratchet step:
1. Generate new X25519 keypair
2. DH with remote party's latest public key
3. HKDF: root_key + dh_output → new_root_key + new_chain_key
4. Delete old root key, old DH private key, old chain key
KDF Chain Details
Root Key Derivation
KDF_RK(root_key, dh_output) → (new_root_key, chain_key, header_key)
Implementation:
prk = HKDF-Extract(salt=root_key, ikm=dh_output)
output = HKDF-Expand(prk, info="rvnt-root-chain", len=96)
new_root_key = output[0..32] // 32 bytes
chain_key = output[32..64] // 32 bytes
header_key = output[64..96] // 32 bytes Chain Key Derivation
KDF_CK(chain_key) → (new_chain_key, message_key)
Implementation:
new_chain_key = HMAC-SHA256(key=chain_key, data=0x02)
message_key = HMAC-SHA256(key=chain_key, data=0x01)
Properties:
- Deterministic: same chain_key always produces same outputs
- One-way: cannot derive chain_key from new_chain_key or message_key
- Independent: message_key and new_chain_key are computationally independent Message Encryption
Encrypt(message_key, message_number, header_bytes, plaintext):
nonce = BE32(message_number) || random_64bit() // 96 bits total
ciphertext = AES-256-GCM(
key: message_key,
nonce: nonce,
aad: header_bytes, // Associated data: header is authenticated
plaintext: compressed_message
)
secure_zero(message_key) // Delete immediately
return (nonce, ciphertext, tag) Skip Keys and Out-of-Order Delivery
Network conditions can cause messages to arrive out of order. If message #5 arrives before message #3, the recipient needs to be able to decrypt both. The Double Ratchet handles this by pre-computing and storing "skipped" message keys.
When receiving message #5, but current counter is at #2:
Advance chain: ck[2] → ck[3] → ck[4] → ck[5]
│ │ │
mk[2] mk[3] mk[4] (stored as skipped keys)
Use mk[5] to decrypt message #5 immediately.
Store mk[2], mk[3], mk[4] for when those messages arrive.
When message #3 arrives later:
Look up mk[3] in skipped keys map.
Decrypt with mk[3].
Delete mk[3] from skipped keys.
Skipped key storage format:
HashMap<(dh_public_key, message_number), message_key>
Key: tuple of (which DH ratchet epoch, which message number)
Value: the 32-byte message key Max Skip Limit: 2000
RVNT enforces a maximum skip limit of 2000 messages. If a received message's number is more than 2000 ahead of the current counter, the message is rejected.
In practice, hitting the 2000 limit is unlikely. It would require sending 2000 consecutive messages without receiving any acknowledgment, which implies either extreme network disruption or a one-directional broadcast pattern.
Forward Secrecy Proof
Forward secrecy means: compromise of long-term keys does not reveal past session keys. In the Double Ratchet, forward secrecy is provided at two levels:
Within a Chain (Symmetric Ratchet)
Given: chain_key[n]
Can derive: chain_key[n+1], chain_key[n+2], ... (forward)
Cannot derive: chain_key[n-1], chain_key[n-2], ... (backward)
Proof sketch:
chain_key[n+1] = HMAC-SHA256(chain_key[n], 0x02)
HMAC-SHA256 is a PRF (pseudorandom function).
Inverting a PRF requires breaking the underlying hash function (SHA-256).
SHA-256 is preimage resistant.
Therefore: chain_key[n] cannot be derived from chain_key[n+1]. Across Chains (DH Ratchet)
Given: current root_key and DH state
Cannot derive: previous root_key values
Proof sketch:
new_root_key = HKDF(root_key, DH(new_priv, their_pub))
The DH private key (new_priv) is generated fresh and deleted after use.
Even knowing new_root_key and DH output, the previous root_key
cannot be recovered because HKDF is a one-way extraction.
Additionally: the DH private key is deleted after the ratchet step.
Even if an attacker captures memory after the step, the private key
is gone. They cannot recompute the DH output for previous steps. Header Encryption
RVNT encrypts message headers to prevent an observer from learning the ratchet state. Without header encryption, an observer could see:
- The DH ratchet public key (reveals when ratchet steps occur)
- The message number (reveals message ordering and frequency)
- The previous chain length (reveals conversation patterns)
Header = {dh_public, prev_chain_len, message_number}
Encrypted header:
enc_header = AES-256-GCM(
key: header_key, // Derived from root chain
nonce: header_nonce, // Counter-based
data: serialize(Header)
)
The recipient tries decryption with the current header key.
If that fails, they try the "next" header key (in case a DH ratchet
step occurred). If both fail, the message cannot be processed. State Diagram
┌──────────────────────────────┐
│ INITIALIZED │
│ root_key = SK (from X3DH) │
│ dh_self = fresh keypair │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
┌────►│ SENDING │
│ │ Advance send chain │
│ │ Derive message key │
│ │ Encrypt + delete key │
│ │ Increment send counter │
│ └──────────────┬───────────────┘
│ │
│ ┌──────────────▼───────────────┐
│ │ RECEIVE (same epoch) │
│ │ Advance recv chain │
│ │ Derive message key │
│ │ Decrypt + delete key │
│ │ Increment recv counter │
│ └──────────────┬───────────────┘
│ │
│ ┌──────────────▼───────────────┐
│ │ DH RATCHET STEP │
│ │ 1. Generate new keypair │
│ │ 2. DH(new_priv, remote_pub) │
│ │ 3. HKDF → new root + chain │
│ │ 4. Delete old keys │
│ │ 5. Reset send counter │
└─────│ 6. Ready to send │
└──────────────────────────────┘
Implementation Notes
Memory Safety
All key material is stored in zeroize::Zeroizing<[u8; 32]> wrappers that guarantee zeroing on drop, even in the presence of panics. The zeroize crate uses compiler barriers and volatile writes to prevent dead-store elimination by LLVM.
Serialization
Ratchet state is serialized to the SQLCipher database using Protocol Buffers. The database itself is encrypted with the user's PIN-derived key (Argon2id). Ratchet state is never written to disk in plaintext.
Concurrency
Ratchet state is protected by a per-session mutex. Concurrent sends to the same session are serialized to prevent chain key race conditions. Concurrent receives are handled by the skipped keys mechanism.
Further Reading
- Key Exchange (X3DH) -- How the initial shared secret is established
- Forward Secrecy -- Practical implications of forward secrecy
- Protocol Specification -- Complete wire formats and test vectors