Understanding the Double Ratchet

If X3DH is the handshake, the Double Ratchet is the engine. Once Alice and Bob have established their initial shared secret through X3DH, the Double Ratchet takes over continuously evolving their encryption keys with every message sent.

The Two Ratchets

The “double” in Double Ratchet refers to two independent ratcheting mechanisms:

flowchart TB
    subgraph "Double Ratchet Architecture"
        direction TB
        
        subgraph "DH Ratchet (Root Chain)"
            DH1[New Ephemeral Key] --> DH2[DH Exchange]
            DH2 --> DH3[New Root Key]
            DH3 --> DH4[New Chain Keys]
            style DH1 fill:#e3f2fd
            style DH2 fill:#e8f5e9
            style DH3 fill:#fff3e0
            style DH4 fill:#fce4ec
        end
        
        subgraph "Symmetric Ratchet (Message Chain)"
            SK1[Chain Key] --> KDF[KDF]
            KDF --> MK[Message Key]
            KDF --> SK2[Next Chain Key]
            MK --> ENC[Encrypt Message]
            SK2 --> KDF
            style SK1 fill:#f3e5f5
            style KDF fill:#e1f5fe
            style MK fill:#e8f5e9
        end
        
        DH4 -.->|Seeds| SK1
    end

1. The Diffie-Hellman Ratchet (Root Chain)

Every time Alice replies to Bob, she generates a new ephemeral key pair and sends the public key with her message. Bob does the same when he replies.

Each new DH exchange produces a new shared secret that feeds into the root key. This provides future secrecy: if a key is compromised, future messages are still secure because the keys keep changing.

Alice's ephemeral key A1  +  Bob's ephemeral key B1  →  DH output
Bob's ephemeral key B1    +  Alice's ephemeral key A1  →  Same DH output

The DH output feeds into a KDF (Key Derivation Function) to produce:

  1. A new root key
  2. A new chain key for sending/receiving

2. The Symmetric Ratchet (Message Chain)

Within a single “DH ratchet step,” multiple messages can be sent. Each message uses a new message key derived from the current chain key.

Chain Key 0  →  KDF  →  Message Key 0  +  Chain Key 1
Chain Key 1  →  KDF  →  Message Key 1  +  Chain Key 2
Chain Key 2  →  KDF  →  Message Key 2  +  Chain Key 3

This provides break-in recovery: even if a message key is compromised, the chain key continues evolving, so future messages are secure.

Visualizing the Ratchet

flowchart TB
    subgraph "Initial State (from X3DH)"
        ROOT0[Root Key 0]
    end
    
    subgraph "Alice Sends Messages 1-3"
        direction LR
        ROOT0 --> SK0_A[Send Chain Key 0]
        SK0_A --> KDF1_A[KDF]
        KDF1_A --> MK1_A[Msg Key 1]
        KDF1_A --> SK1_A[Send Chain Key 1]
        
        SK1_A --> KDF2_A[KDF]
        KDF2_A --> MK2_A[Msg Key 2]
        KDF2_A --> SK2_A[Send Chain Key 2]
        
        SK2_A --> KDF3_A[KDF]
        KDF3_A --> MK3_A[Msg Key 3]
        KDF3_A --> SK3_A[Send Chain Key 3]
    end
    
    subgraph "Bob Replies with New DH Key"
        SK3_A --> EPH[Bob's New Ephemeral DH]
        EPH --> DH[DH Exchange]
        DH --> ROOT1[Root Key 1]
        ROOT1 --> RK_B[Recv Chain Key]
        ROOT1 --> SK_B[Send Chain Key]
    end
    
    subgraph "Bob Sends Messages 4-5"
        SK_B --> KDF1_B[KDF]
        KDF1_B --> MK4_B[Msg Key 4]
        KDF1_B --> SK4_B[Send Chain Key 4]
    end
    
    style ROOT0 fill:#e3f2fd
    style ROOT1 fill:#e3f2fd
    style EPH fill:#fff3e0
    style DH fill:#e8f5e9

Each DH ratchet step (when someone replies with a new ephemeral key) creates a new root key, which then seeds new sending and receiving chains. Within each chain, the symmetric ratchet advances for every message.

When Alice receives a message from Bob with a new ephemeral DH public key, she:

  1. Performs DH with her current private key and his new public key
  2. Feeds the result into the root chain
  3. Derives new sending and receiving chain keys
  4. Continues the symmetric ratchet from there

The Message Structure

Each Signal Protocol message contains:

{
  "ciphertext": "base64 encrypted content",
  "ephemeral_key": "Bob's new Curve25519 public key (optional)",
  "message_number": 42,
  "previous_chain_length": 10
}

The ephemeral_key only appears when the sender has generated a new DH key pair triggering a “DH ratchet step” on the receiving side.

Handling Out-of-Order Messages

Real networks are messy. Messages arrive out of order, get lost, or are duplicated. The Double Ratchet handles this through a skipped message key cache.

sequenceDiagram
    participant Alice
    participant Server
    participant Bob
    
    Note over Alice,Bob: Normal flow
    Alice->>Server: Message 1 (MsgKey1)
    Alice->>Server: Message 2 (MsgKey2)
    Alice->>Server: Message 3 (MsgKey3)
    
    Note over Server: Network issues
    Server-->>Bob: Message 1 ✓
    Server--xBob: Message 2 ✗ (lost)
    Server-->>Bob: Message 3 ✓
    
    Note over Bob: Bob receives Message 3 before Message 2
    Bob->>Bob: Sees gap (expected #2, got #3)
    Bob->>Bob: Derive MsgKey2, cache it
    Bob->>Bob: Derive MsgKey3, decrypt
    
    Note over Server: Later...
    Server-->>Bob: Message 2 (delayed)
    Bob->>Bob: Check cache  MsgKey2 found!
    Bob->>Bob: Decrypt with cached key

When Alice is at message number 5 and receives message number 8, she:

  1. Ratchets forward to derive keys 6, 7, and 8
  2. Stores keys 6 and 7 in the “skipped keys” cache
  3. Decrypts message 8
  4. When messages 6 or 7 arrive later, uses the cached keys
// Conceptual skipped key handling
Future<String> decryptWithSkippedKeys(
  int receivedMessageNumber,
  String ciphertext,
) async {
  final currentChainLength = getCurrentChainLength();
  
  if (receivedMessageNumber > currentChainLength) {
    // Future message  ratchet forward and cache skipped keys
    for (int i = currentChainLength; i < receivedMessageNumber; i++) {
      final (messageKey, nextChainKey) = kdf(chainKey);
      await storeSkippedKey(i, messageKey);
      chainKey = nextChainKey;
    }
  } else if (receivedMessageNumber < currentChainLength) {
    // Out-of-order message  check skipped key cache
    final cachedKey = await getSkippedKey(receivedMessageNumber);
    if (cachedKey != null) {
      return decrypt(ciphertext, cachedKey);
    }
    throw Exception('Message key already used or too old');
  }
  
  // Current message  use current chain key
  final (messageKey, nextChainKey) = kdf(chainKey);
  chainKey = nextChainKey;
  return decrypt(ciphertext, messageKey);
}

Implementation: The Critical Cipher State Recreation

When I implemented group chat, I hit a brutal bug: messages would fail to decrypt intermittently. After days of debugging, I realized the issue was cipher state reuse.

The libsignal library’s SessionCipher maintains internal state. If a decryption fails (malformed message, network corruption), the cipher state is left in an undefined condition. Retrying with the same cipher instance produces garbage.

The fix: recreate the cipher from scratch on every decryption attempt.

Future<String> decryptGroupMessage(
  String groupId,
  String senderId,
  String ciphertext,
) async {
  // Fetch or create the session from secure storage
  final session = await _getOrCreateSenderKeySession(groupId, senderId);
  
  // CRITICAL: Always recreate cipher state fresh
  // Reusing after a failed attempt corrupts the ratchet
  final cipher = GroupCipher(session);
  
  try {
    final plaintext = cipher.decrypt(base64Decode(ciphertext));
    // Persist updated session state BEFORE returning
    await _storeSession(senderId, 1, session);
    return utf8.decode(plaintext);
  } catch (e) {
    // Session state is still valid  cipher was created fresh
    rethrow;
  }
}

This pattern applies to one-to-one Double Ratchet sessions too. Never reuse a SessionCipher across multiple operations if any might fail.

Root Key Derivation

The KDF (Key Derivation Function) is typically HKDF with SHA-256. The root key derivation looks like:

root_key[i+1], chain_key_send[i+1], chain_key_recv[i+1] = 
    HKDF-SHA256(
        input_key_material = DH_output || root_key[i],
        salt = 0x00 * 32,
        info = "SignalProtocolV1"
    )

The input combines the new DH output (for future secrecy) with the previous root key (chaining the history).

Symmetric Ratchet KDF

Within a chain, message keys are derived as:

message_key[i], chain_key[i+1] = 
    HKDF-SHA256(
        input_key_material = chain_key[i],
        salt = 0x00 * 32,
        info = "SignalMessageKeys"
    )

Each step produces a 32-byte message key for AES-256-CBC encryption (with HMAC-SHA256 for authentication), plus the next 32-byte chain key.

Group Chat: Sender Keys

Group messages use a variant called “sender keys.” Each member generates a single key chain for the group, and everyone else fetches that member’s public sender key.

flowchart TB
    subgraph "1-to-1 Double Ratchet"
        direction TB
        A1[Alice] <-->|"DH Ratchet<br/>every reply"| B1[Bob]
    end
    
    subgraph "Group Sender Keys"
        direction TB
        A2[Alice] -->|"Sends sender key"| G[(Group Server)]
        B2[Bob] -->|"Sends sender key"| G
        C[Charlie] -->|"Sends sender key"| G
        
        G -->|"All fetch<br/>each other's keys"| A2
        G -->|"All fetch<br/>each other's keys"| B2
        G -->|"All fetch<br/>each other's keys"| C
    end
    
    subgraph "Key Usage Difference"
        ONE[1-to-1: DH + Symmetric<br/>Future Secrecy ✓]
        GRP[Group: Symmetric only<br/>No Future Secrecy ✗]
    end

Unlike one-to-one Double Ratchet where each reply triggers a DH step, group sender keys use a pure symmetric ratchet. The sender advances their chain for each message; recipients advance their copy when decrypting.

The tradeoff: no future secrecy within the group. If a sender key is compromised, all future messages from that sender are at risk until they distribute a new sender key.

Code: Encrypting a Message

Future<EncryptedMessage> encryptMessage(
  String recipientId,
  String plaintext,
) async {
  // Load or establish session (via X3DH if new)
  final session = await _loadOrCreateSession(recipientId);
  
  // Create fresh cipher from session state
  final cipher = SessionCipher(session);
  
  // Encrypt  this advances the sending chain internally
  final ciphertext = cipher.encrypt(utf8.encode(plaintext));
  
  // Check if we should include a new ephemeral DH public key
  // (Happens on first message or periodically)
  final ephemeralKey = cipher.hasNewEphemeralKey 
      ? session.localEphemeralPublicKey 
      : null;
  
  // Persist updated session with new chain keys
  await _storeSession(recipientId, 1, session);
  
  return EncryptedMessage(
    ciphertext: base64Encode(ciphertext),
    ephemeralKey: ephemeralKey,
    messageNumber: session.sendingChainLength - 1,
  );
}

Security Properties

Perfect Forward Secrecy

flowchart TB
    subgraph "Compromise at Time T"
        COMP[🔴 Key Compromised]
    end
    
    subgraph "Past Messages"
        PAST1[Msg 1: Destroyed key]
        PAST2[Msg 2: Destroyed key]
        PAST3[Msg 3: Destroyed key]
        SAFE1[✅ Safe  keys destroyed]
    end
    
    subgraph "Future Messages"
        FUTURE1[Msg N: New DH ratchet]
        FUTURE2[Msg N+1: New chain key]
        SAFE2[✅ Safe  self-healing]
    end
    
    COMP -.->|Can't recover| PAST1
    COMP -.->|Can't recover| PAST2
    COMP -.->|New ratchet| FUTURE1

If long-term private keys are compromised today, past messages remain secure because:

  • Ephemeral DH keys were destroyed after use
  • Chain keys were destroyed after deriving message keys
  • Message keys were destroyed after encrypting/decrypting

Only actively held message keys (unread messages) are at risk.

Future Secrecy (Self-Healing)

If a message key is compromised today, future messages are secure because:

  • Each reply triggers a new DH ratchet step
  • New chain keys are derived from fresh DH outputs
  • The compromised key’s chain is abandoned

This is why Signal and my messenger can say “messages are secure even if a key leaks.”

Common Pitfalls

  1. Not persisting session state Every ratchet step must be saved to disk. Losing state means losing the ability to decrypt.

  2. Reusing cipher instances As I learned, always create fresh ciphers from persisted session state.

  3. Not handling skipped keys Out-of-order messages are normal. Implement skipped key caching or users lose messages.

  4. Message number validation Accepting old message numbers without checking for replays allows message replay attacks.

Summary

The Double Ratchet is brilliant in its simplicity: two interlocking ratchets (DH and symmetric) that together provide both forward secrecy and future secrecy. The DH ratchet heals from compromises; the symmetric ratchet provides break-in recovery.

Understanding this algorithm was the turning point in my messenger project. Once the ratchet clicked, everything else group chat, media encryption, multi-device built naturally on top of it.

For the initial key exchange that seeds this ratchet, read about X3DH (Extended Triple Diffie-Hellman).

Random Fact

The 'QWERTY' keyboard layout was designed to slow down typing to prevent typewriter jams.