Double Ratchet Algorithm
The cryptographic engine behind every Encra message. Every time Alice sends Bob a message, three keys advance β and anything the attacker captured before that moment becomes permanently unreadable.
π‘ TL;DR
History & origin
The Double Ratchet Algorithm was designed by Trevor Perrin and Moxie Marlinspike at Open Whisper Systems (now Signal Foundation) in 2013, initially shipped in the TextSecure app.1 It evolved from Stuart Anderson's Off-the-Record (OTR) messaging protocol2 by replacing OTR's three-message handshake with an always-on ratchet, eliminating the need to re-negotiate keys mid-conversation.
The formal specification was published in November 20161 and independently analysed by Cohn-Gordon et al. in 20163 and by Alwen, Coretti & Dodis in 2019 (the βACDβ paper)4 which proved the protocol's security under the standard model. Today the same algorithm runs inside Signal, WhatsApp (1 billion+ users), Facebook Messenger (secret conversations), Skype (private conversations), and Google Messages (RCS E2E).
Three keys, three jobs
Every session maintains exactly three long-lived state values:
64 bytes. Seeded from the X3DH handshake. Advanced only on DH ratchet steps. Acts as the master secret that feeds both sending and receiving chain keys.
32 bytes. One per direction (send / receive). Advanced on every message. Never leaves the device β used only to derive per-message keys.
32 bytes. Derived from the chain key, used to encrypt exactly one message, then zeroed from memory. Enables forward secrecy.
The KDF chain (symmetric ratchet)
For every message sent, the chain key advances once through a keyed hash function (KDF). Two outputs are produced from a single call:
KDF(CK, 0x02)β next chain key (replaces the current one)KDF(CK, 0x01)β message key (used once to encrypt, then deleted)
KDF Chain (one direction β sender or receiver)
Because the KDF is a one-way function, the chain can only move forward. An attacker who steals CK_n can derive MK_n, MK_{n+1}, β¦ but cannot reverse-derive CK_{n-1} or any previous message keys β those are already gone.
βΉ Encra's KDF primitive
libsodium crypto_generichash5) as the KDF, passing the constant byte as the data and the chain key as the BLAKE2b key. BLAKE2b is faster than HMAC-SHA-256 and provides equivalent security for this use-case. The Signal spec uses HMAC-SHA-256; both are valid instantiations of the abstract KDF interface.The DH ratchet (asymmetric ratchet)
The KDF chain alone provides forward secrecy, but not break-in recovery β if an attacker steals the current chain key, all future messages are compromised until the conversation ends. The Diffie-Hellman ratchet fixes this.
Every message header carries the sender's current ephemeral ratchet public key. When the receiver sees a new public key, they:
- Perform
DH(their_priv, new_pub)β feed the output into the root KDF β derive a new receiving chain key. - Generate a fresh ephemeral key pair of their own.
- Perform
DH(new_priv, new_pub)β feed into root KDF β derive a new sending chain key.
DH Ratchet step (when a new message arrives with a new DH public key)
Because fresh DH key material is mixed into the root key, any chain-key compromise is healed after the next DH step. An attacker would need to also compromise the new ephemeral private key β which was just generated and never transmitted.
Combined conversation flow
In practice, both ratchets run together: the KDF chain advances on every message; the DH ratchet advances whenever the receiver sees a new ratchet public key in the message header.
Encra implementation
The full implementation lives in packages/core/src/crypto/ratchet.ts. The public API is a single class:
export class DoubleRatchet {
/** Start a session as the sender (Alice) after X3DH handshake */
static initSender(
sharedSecret: Uint8Array, // 32-byte secret from X25519
recipientPublicKey: Uint8Array, // Bob's long-term or signed pre-key
): DoubleRatchet
/** Start a session as the receiver (Bob) after X3DH handshake */
static initReceiver(
sharedSecret: Uint8Array,
localKeyPair: KeyPair, // Bob's ratchet key pair
): DoubleRatchet
/** Encrypt a plaintext string. Returns header + ciphertext + nonce. */
encrypt(plaintext: string): EncryptedMessage
/** Decrypt an EncryptedMessage. Throws DecryptionFailedError on any failure. */
decrypt(msg: EncryptedMessage): string
}interface EncryptedMessage {
header: {
publicKey: Uint8Array // sender's current ratchet public key
n: number // message number within the sending chain
pn: number // message number of last msg in previous sending chain
}
ciphertext: Uint8Array // XSalsa20-Poly1305 ciphertext
nonce: Uint8Array // 24-byte random nonce
}Session initialization
Sessions are bootstrapped with the shared secret from X25519 key exchange. Alice feeds it into the root KDF along with a fresh ephemeral key pair; Bob starts with just the shared secret and his ratchet key pair.
import { generateKeyPair, deriveSharedSecret } from '@encra/core'
import { DoubleRatchet } from '@encra/core'
// --- Alice's device ---
const aliceKeys = await generateKeyPair()
const sharedSecret = await deriveSharedSecret(aliceKeys.privateKey, bobPublicKey)
const aliceRatchet = DoubleRatchet.initSender(sharedSecret, bobPublicKey)
const encrypted = aliceRatchet.encrypt('Hello Bob!')
// β { header, ciphertext, nonce }
// --- Bob's device ---
const bobRatchet = DoubleRatchet.initReceiver(sharedSecret, bobKeyPair)
const plaintext = bobRatchet.decrypt(encrypted)
// β 'Hello Bob!'Out-of-order message handling
Network reordering is common in mobile environments. When Alice sends messages 1β5 but Bob receives them in order 1, 3, 2, 5, 4 β the ratchet must still decrypt all of them correctly.
The pn (previous-chain-N) field in the header tells the receiver how many messages were sent in the previous sending chain. When a DH ratchet step is detected, Bob computes and stores all skipped message keys from the previous chain before advancing. Skipped keys for the current chain are stored as they are encountered. Both pools are bounded by MAX_SKIP_KEYS = 1000 to prevent memory exhaustion attacks.
β Skipped key lifetime
Security properties
| Security property | Encra | Details |
|---|---|---|
| Forward Secrecy | β Yes | Past messages cannot be decrypted even if the current session state is compromised. Message keys are deleted immediately after use. |
| Break-in Recovery | β Yes | After an attacker gains session state, future message security is automatically restored on the next DH ratchet step. |
| Message Integrity | β Yes | XSalsa20-Poly1305 provides authenticated encryption β any bit-flip in transit is detected and the message rejected. |
| Replay Protection | β Yes | Message counters (N) are tracked. A replayed message index within a chain will be skipped or rejected. |
| Out-of-order delivery | β Yes | Skipped message keys are stored (up to MAX_SKIP_KEYS = 1000) so messages can arrive and decrypt out of order. |
| Deniability | β Yes | Shared ephemeral DH keys mean neither party can cryptographically prove a message was sent by the other. Same as Signal. |
| Identity hiding | β Out of scope | Message headers contain the sender's ratchet public key in plaintext. Transport-layer privacy (TLS / Tor) is required for anonymity. |
| Group messaging | β Out of scope | Double Ratchet is a pairwise protocol. Sender Keys (Signal) or MLS (RFC 9420) are needed for groups. |
π¨ What Double Ratchet does NOT protect against
- Compromised endpoint β if an attacker installs malware on Alice's device and reads plaintext from the screen or memory, no protocol can help.
- Long-term identity key theft β stealing the X25519 long-term private key enables MITM on future sessions. Protect it with IndexedDB + device biometrics.
- Traffic analysis β message sizes, timing, and frequency are visible to the server. Use sealed-sender envelopes and padding for metadata resistance.
Signal spec vs Encra implementation
| Aspect | Signal spec | Encra |
|---|---|---|
| KDF primitive | HMAC-SHA-256 | BLAKE2b-256 (libsodium crypto_generichash) |
| DH function | Curve25519 X25519 | X25519 via libsodium crypto_scalarmult |
| Message encryption | AES-256-CBC + HMAC or AES-GCM | XSalsa20-Poly1305 (libsodium crypto_secretbox) |
| Header encryption | Optional (HKDF-SHA-256) | Not yet implemented (roadmap) |
| MAX_SKIP_KEYS | β₯ 1000 (recommendation) | 1000 (hard limit) |
| Skipped key persistence | Implementation-defined | In-memory only (roadmap: IndexedDB) |
| Initial keying | X3DH handshake | X25519 ECDH (simplified β no pre-key bundles yet) |
References
- Perrin, T. & Marlinspike, M. β The Double Ratchet Algorithm (Signal Foundation, 2016)
- Borisov, N., Goldberg, I. & Brewer, E. β Off-the-Record Communication (ACM WPES 2004)
- Cohn-Gordon, K. et al. β On the Formal Security of Signal (IEEE EuroS&P 2017, ePrint 2016/221)
- Alwen, J., Coretti, S. & Dodis, Y. β The Double Ratchet: Security Notions, Proofs, and Modularization (EUROCRYPT 2019, ePrint 2019/800)
- libsodium β Generic hashing (crypto_generichash / BLAKE2b)
- Perrin, T. & Marlinspike, M. β The X3DH Key Agreement Protocol (Signal Foundation, 2016)
- Marlinspike, M. β Advanced cryptographic ratcheting (Signal blog, 2013)