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".
Key derivation functions
Section titled “Key derivation functions”| 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.
DH ratchet step
Section titled “DH ratchet step”When a received header carries a DH public key the session has not seen, the receiver performs a full ratchet step:
- Record the previous sending chain length; reset both message counters.
- Derive the receiving chain:
(RK', CK_recv) = KDF_RK(RK, DH(our_current_private, their_new_public)). - Generate a fresh local X25519 key pair.
- 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.
Low-order point rejection
Section titled “Low-order point rejection”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.
Responder bootstrap
Section titled “Responder bootstrap”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.
Message header
Section titled “Message header”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.
Payload format
Section titled “Payload format”Each message key is a 32-byte AES-256 key used exactly once. The ciphertext layout is:
[ 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.
Replay protection
Section titled “Replay protection”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.
Out-of-order messages
Section titled “Out-of-order messages”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.
Session lifecycle
Section titled “Session lifecycle”| 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.
Security properties
Section titled “Security properties”| 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 |
