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.

Why the limit exists: Without a skip limit, a malicious sender could claim a message number of 2^32, forcing the recipient to derive and store billions of skipped keys, exhausting memory and CPU. The 2000 limit prevents this denial-of-service attack while accommodating realistic out-of-order delivery scenarios.

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

Last updated: 2026-04-12

RVNT Documentation — Post-quantum encrypted communications