All posts
algorithmencryptionsecurity

The Double Ratchet Algorithm: How Signal-Level Encryption Actually Works

A step-by-step visual walkthrough of the Double Ratchet — the cryptographic protocol behind Signal, WhatsApp, and Encra. Understand forward secrecy, break-in recovery, and why it's so hard to get right.

E

Encra Team

Engineering

5 min read1,375 words

Every time you send a message in Signal or WhatsApp, the Double Ratchet algorithm runs. It derives a unique encryption key for that specific message, uses it exactly once, then deletes it.

This sounds simple. The implications are profound.

Reading order

This article assumes you know the basics of encryption and Diffie-Hellman key exchange. If you're starting from scratch, read What Is Encryption? first — it takes 10 minutes and has interactive demos.

The problem: why can't we just use one key?

Imagine Alice and Bob exchange a shared secret once (via Diffie-Hellman), then use it to encrypt all their messages:

Shared secret:  0x9a3d6f...

Message 1:  encrypt("Hey!", shared_secret) → ciphertext1
Message 2:  encrypt("How are you?", shared_secret) → ciphertext2
Message 3:  encrypt("Want to call?", shared_secret) → ciphertext3

This works — but it has a catastrophic failure mode.

If the shared secret is ever compromised, an attacker can decrypt every past and future message. All of them. Going back to the beginning of the conversation.

This is the static key problem. Real-world threat: your device gets stolen, malware exfiltrates your memory, a government compels you to hand over your keys. Any of these exposes your entire message history.

We need a better model.

The ratchet concept: keys that can only advance

A ratchet is a mechanical device that turns in one direction only — you can advance it, but never reverse it. The cryptographic analog: a sequence of keys where each key derives the next, but you cannot derive previous keys from later ones.

The simplest version: a hash chain.

Key 0   →  Key 1  →  Key 2  →  Key 3  →  …
KDF(K0)    KDF(K1)   KDF(K2)

Where KDF is a Key Derivation Function (we use BLAKE2b-256). Each step is a one-way transformation — you can compute forward but not backward.

Crucially: after using Key 0 to encrypt a message, you delete it. You only keep Key 1 (the next in the chain). An attacker who gets Key 2 today cannot compute Key 0 or Key 1 — they don't exist anymore.

This gives us forward secrecy: compromise today ≠ compromise of the past.

The symmetric ratchet: advancing with each message

The first half of the Double Ratchet is a symmetric (KDF) chain. It works like this:

  1. Both Alice and Bob start with the same Chain Key (derived from their shared secret)
  2. For each message, the sender derives a Message Key from the Chain Key
  3. The Message Key encrypts exactly one message
  4. After encryption, the Message Key is permanently deleted
  5. The Chain Key "ratchets forward" — deriving the next Chain Key
CK₀ ──KDF──► MK₁  (encrypt message 1, then DELETE MK₁)
 └──────────► CK₁

CK₁ ──KDF──► MK₂  (encrypt message 2, then DELETE MK₂)
 └──────────► CK₂

Each Message Key is unique, used once, then gone. The Chain Key advances in lock-step.

The DH ratchet: periodic key agreement refresh

The symmetric ratchet solves forward secrecy. But it has a weakness: break-in recovery.

Suppose an attacker compromises Alice's device and steals CK₅ (the current chain key). They can now decrypt all future messages until the chain is refreshed.

The solution is the Diffie-Hellman ratchet: periodically, Alice generates a fresh DH keypair and performs a new key exchange with Bob. This derives new Chain Keys from a fresh shared secret — one the attacker never had.

After a DH ratchet step, even if the attacker had CK₅, they can't derive CK_new because it requires the new private key that Alice just generated.

Putting it together: the Double Ratchet

The Double Ratchet combines both:

  1. Symmetric ratchet — advances with every message (forward secrecy)
  2. DH ratchet — advances periodically (break-in recovery)

Work through it step by step:

Interactive: Double Ratchet Algorithm

1. Initial DH Key Agreement

Alice and Bob exchange public keys and compute a shared secret. Their private keys never leave their devices.

Key Agreement

Alice pubkey0x4a2f8e9c
Bob pubkey0x8b1c5f2a
Shared secret0x3f7e1a9cNEW
1 / 9

The demo shows the full lifecycle:

  • Steps 1-2: Initial DH key agreement, ratchet state initialized with Root Key and Chain Keys
  • Steps 3-5: Message 1 — key derived, message encrypted, key deleted
  • Steps 6-7: Message 2 — chain advances, new key, encrypted, deleted
  • Step 8: DH ratchet fires — fresh keypair, new shared secret, new chain keys derived
  • Step 9: Forward secrecy proof — MK1 and MK2 are gone, current compromise can't reach them

Click through each step and observe the key lifecycle closely.

The three guarantees

The Double Ratchet provides three distinct security properties:

1. Forward secrecy

Past message keys are deleted after use. A device compromised today cannot decrypt yesterday's messages.

Attacker steals device today, gets CK₁₀₀
Cannot compute CK₁, CK₂, …, CK₉₉  (one-way KDF)
Cannot compute MK₁ through MK₉₉  (deleted)
Past messages: ✓ safe

2. Break-in recovery

After a compromise, the DH ratchet fires and derives new keys from a fresh shared secret. The attacker's window is limited.

Attacker has CK₁₀₀ at time T
At T+1: DH ratchet fires, derives CK_new from fresh ECDH
Attacker cannot compute CK_new (doesn't have Alice's new private key)
Future messages after ratchet: ✓ safe

3. Out-of-order message handling

Encrypted messages sometimes arrive out of sequence (network reordering). The Double Ratchet handles this via skipped message key caching: if MK₅ arrives after MK₆ was already used, the protocol cached a copy of MK₅ briefly (up to a limit).

The MAX_SKIP parameter

Encra's implementation caches up to 1000 skipped message keys (MAX_SKIP = 1000). This balances out-of-order delivery handling against the memory overhead of caching keys that may never arrive.

Why this is hard to implement correctly

The Double Ratchet was published by Signal's engineers in 2016. It took years of cryptographic review. Getting it wrong in subtle ways breaks the entire security model:

Nonce reuse — If you ever encrypt two messages with the same nonce (IV) and key, the XOR of the two ciphertexts leaks both plaintexts. Every nonce must be unique.

Key deletion timing — Deleting keys too early breaks out-of-order delivery. Deleting too late (or not at all) breaks forward secrecy. The window must be carefully calibrated.

State persistence — The ratchet state (chain keys, DH keys) must be stored securely between sessions. If stored in localStorage (recoverable by scripts), private keys are exposed. Encra uses IndexedDB with the private key marked non-extractable.

DH ratchet timing — The DH ratchet should fire often enough for break-in recovery but not so often it's expensive. Signal fires it every time the direction of communication reverses (Alice sends, then Bob sends → Bob's next send triggers a DH ratchet step).

Side channel leaks — The message header (ratchet key, chain index) is currently plaintext. An observer can build a metadata graph of who communicates with whom. Header encryption is on Encra's roadmap.

Don't implement this yourself

The Double Ratchet is well-specified and well-audited. But the specification is 30 pages of careful edge cases. Implementing it from scratch is how you introduce subtle vulnerabilities that break security silently. Use an audited implementation: libsodium for the cryptographic primitives, and a reviewed ratchet implementation on top.

How Encra implements it

Encra's DoubleRatchet class in @encra/core implements the full specification:

typescript
import { DoubleRatchet, generateKeyPair, exportKey } from "@encra/core"

// Initialize ratchet (Alice's side)
const ratchet = await DoubleRatchet.initAlice({
  ourKeyPair: aliceKeyPair,
  theirPublicKey: bobPublicKey,
})

// Encrypt a message (derives unique key, encrypts, key deleted)
const { header, ciphertext } = await ratchet.encrypt("Hello, Bob!")

// Export state for persistence (keys are never logged)
const state = await ratchet.export()
// Store state in IndexedDB

// Later: restore and continue
const restored = await DoubleRatchet.fromExport(state)
const plaintext = await restored.decrypt(header, ciphertext)
// → "Hello, Bob!"

Key properties of the implementation:

  • 41 tests, 99.7% coverage — every edge case in the spec is covered
  • MAX_SKIP = 1000 — out-of-order message handling
  • libsodium primitives — XSalsa20-Poly1305 for encryption, BLAKE2b for KDF
  • IndexedDB persistence — ratchet state stored on-device, private keys non-extractable
  • Multi-device routing — one encrypted copy per registered device

The complete picture

Here's what happens end-to-end when you use useE2EChat:

1.  Alice's device:   generateKeyPair() → { publicKey, privateKey }
2.  Registration:     POST /v1/keys { userId, deviceId, publicKey }
3.  Fetch keys:       GET /v1/keys/bob → [{ deviceId, publicKey }, ...]
4.  DH exchange:      deriveSharedSecret(alice_priv, bob_pub) → sharedSecret
5.  Ratchet init:     DoubleRatchet.initAlice(alice_keys, bob_pub)
6.  Send:             ratchet.encrypt("Hello!") → { header, ciphertext }
7.  Relay:            WebSocket → server (sees ciphertext, never plaintext)
8.  Receive:          ratchet.decrypt(header, ciphertext) → "Hello!"
9.  Key deleted:      message key gone, chain advances

The server in step 7 routes the message without ever being able to read it. Not now, not in the future, not under subpoena — because the keys to decrypt it never existed on the server.

That's what "zero-knowledge relay" means.


Ready to add Signal-level encryption to your app? Get a free API key at encra.dev/signup — no credit card, 30 seconds to start.