Skip to content

Double Ratchet

After X3DH establishes a shared secret, every message is encrypted under a fresh key produced by the Double Ratchet: an X25519 DH ratchet that rotates keys whenever the conversation changes direction, layered over HMAC-SHA256 symmetric chains that advance with every message.

Implementations: zentalk/src/services/e2ee/ratchet/ (TypeScript) and zentalk-node/pkg/protocol/ratchet.go (Go), sharing the root-key KDF info string "Zentalk Double Ratchet Root".

KDF Definition Output
KDF_RK (root) HKDF-SHA256(ikm = dh_output, salt = root_key, info = "Zentalk Double Ratchet Root", 64) New root key (32 B) || new chain key (32 B)
KDF_CK (chain) message_key = HMAC-SHA256(chain_key, 0x01); chain_key' = HMAC-SHA256(chain_key, 0x02) 32-byte message key + advanced chain key

Using the current root key as the HKDF salt (per the Signal specification) accumulates entropy across ratchet steps: each new root key depends on every DH exchange so far. The 0x01/0x02 constants domain-separate message keys from chain advancement, making the chain one-way — compromise of a chain key never reveals earlier message keys.

When a received header carries a DH public key the session has not seen, the receiver performs a full ratchet step:

  1. Record the previous sending chain length; reset both message counters.
  2. Derive the receiving chain: (RK', CK_recv) = KDF_RK(RK, DH(our_current_private, their_new_public)).
  3. Generate a fresh local X25519 key pair.
  4. Derive the sending chain: (RK'', CK_send) = KDF_RK(RK', DH(our_new_private, their_new_public)).

This provides post-compromise security: once a party contributes a fresh DH key pair, an attacker who captured earlier state can no longer derive subsequent message keys.

Before any ratchet step, the peer’s DH public key is checked against the RFC 7748 §5 list of X25519 low-order points (0, 1, the two low-order group elements, and p-1, p, p+1). These points force a predictable shared secret regardless of the private scalar; they are rejected at both header parse time and decrypt time.

A newly created responder session holds an all-zero sentinel as its “last seen” peer DH key. The implementation forces the DH-ratchet branch on the first decrypt regardless of header equality (hasReceivedFirstDHRatchet flag) — otherwise an attacker sending an all-zero header key could match the sentinel and skip the ratchet step entirely.

Two on-wire header formats exist; the receiver distinguishes them by length and leading byte:

Version Length Layout AAD binding
v1 (current) 41 bytes 0x01 || DH public key (32) || previous chain length (4, BE) || message number (4, BE) The exact 41 header bytes are passed to AES-GCM as additional authenticated data
v0 (legacy) 40 bytes DH public key (32) || previous chain length (4, BE) || message number (4, BE) None (accepted for backward compatibility on decrypt only)

AAD binding means any flipped header field invalidates the GCM authentication tag. New sessions always send v1.

Each message key is a 32-byte AES-256 key used exactly once. The ciphertext layout is:

AES-256-GCM payload
[ IV: 12 bytes ][ ciphertext: variable ][ auth tag: 16 bytes ]
  • IVs are 96-bit random values from the platform CSPRNG.
  • The message key is zeroized immediately after use, on both encrypt and decrypt.

IVs are tracked per session in two tiers: an in-memory set and a persisted (IndexedDB) store keyed by the session, so replays are detected even across page reloads. IV lookups use constant-time comparison to avoid timing side channels. A replayed IV fails decryption with a generic error. Messages re-decrypted from trusted local cache may skip the replay check explicitly; network messages never do.

The receiver derives and stores skipped message keys so late arrivals still decrypt:

Limit Value Purpose
MAX_SKIP 1,000 Maximum gap tolerated in a single chain; larger gaps are rejected as potentially malicious
MAX_TOTAL_SKIPPED_KEYS 10,000 Global cap across all skipped keys; oldest entries are evicted (LRU by timestamp) and zeroized
MAX_MESSAGE_NUMBER 2³¹ − 1 Upper bound on counters, preventing integer-overflow attacks

Skipped keys are indexed by hex(dh_public_key):message_number, consumed on first use, and zeroized after decryption.

Parameter Value
Hard session timeout 7 days without use — session must be re-established
Stale-session cleanup age 24 hours
Cleanup interval 1 hour

Broken sessions (persistent decryption failures) are tracked by a failure tracker and rebuilt automatically by re-running X3DH against a freshly fetched key bundle. A rebuilt session produces a new root key — which is why the SAS verification code changes and users are prompted to re-verify.

Property Mechanism
Forward secrecy One-way symmetric chains; message keys deleted after use
Post-compromise security Fresh DH key pair on every ratchet step
Header integrity v1 header bound as AES-GCM AAD
Replay resistance Two-tier IV tracking with constant-time lookup
DoS resistance Skip limits, global skipped-key cap, counter bounds