Understanding the Double Ratchet
Contents
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:
- A new root key
- 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:
- Performs DH with her current private key and his new public key
- Feeds the result into the root chain
- Derives new sending and receiving chain keys
- 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:
- Ratchets forward to derive keys 6, 7, and 8
- Stores keys 6 and 7 in the “skipped keys” cache
- Decrypts message 8
- 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
-
Not persisting session state Every ratchet step must be saved to disk. Losing state means losing the ability to decrypt.
-
Reusing cipher instances As I learned, always create fresh ciphers from persisted session state.
-
Not handling skipped keys Out-of-order messages are normal. Implement skipped key caching or users lose messages.
-
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).