Introduction

A conversation on your phone can last for years. If every message were encrypted with the same key, a single leak, a stolen phone or one malware infection would expose every message ever sent, and every future one too. Good messaging cryptography tries to make a stolen key worth as little as possible.

Two properties capture that goal:

  • Forward secrecy: if an attacker steals your keys today, messages from yesterday stay unreadable, because the keys that protected them have already been deleted.
  • Post-compromise security (also called self-healing or backward secrecy): if the attacker stole your keys today but then loses access, messages from tomorrow become unreadable again, because fresh randomness has been mixed into the keys.

The Double Ratchet achieves both. It was designed by Trevor Perrin and Moxie Marlinspike; its roots go back to a 2013 design called the Axolotl ratchet, and it is specified today in the Signal documentation (revision 4 is dated November 2025). It powers the Signal protocol, which WhatsApp finished rolling out to all of its users in April 2016.

Two ratchets, one idea

A ratchet is a mechanism that turns only one way. Here, the keys can only move forward:

  1. The symmetric-key ratchet (a KDF chain) hashes a chain key forward with HMAC: each step outputs a one-time message key and the next chain key. Since HMAC cannot be run backwards, old message keys cannot be recomputed from a stolen chain key. This gives forward secrecy.
  2. The Diffie–Hellman ratchet has the two sides keep exchanging fresh public keys inside their message headers. Every time a new key arrives, both sides run a new Diffie–Hellman agreement and feed the result into a root key, which restarts both chains. An attacker holding an old state lacks the new private key, so she is locked out. This gives post-compromise security.

Before the first message, the two parties need a shared secret to start from. In Signal that comes from the X3DH key agreement (and, since 2023, its post-quantum variant PQXDH), which works even when the recipient is offline. The Double Ratchet takes over from there.

RatchetSend(state, plaintext):
    state.CKs, mk = KDF_CK(state.CKs)
    header = (state.DHs.public, state.PN, state.Ns)
    state.Ns += 1
    return header, Encrypt(mk, plaintext, header)

RatchetReceive(state, header, ciphertext):
    if header.dh != state.DHr:           # a new ratchet key arrived
        SkipMessageKeys(state, header.pn)
        DHRatchet(state, header)
    SkipMessageKeys(state, header.n)
    state.CKr, mk = KDF_CK(state.CKr)
    state.Nr += 1
    return Decrypt(mk, ciphertext, header)

DHRatchet(state, header):
    state.PN, state.Ns, state.Nr = state.Ns, 0, 0
    state.DHr = header.dh
    state.RK, state.CKr = KDF_RK(state.RK, DH(state.DHs, state.DHr))
    state.DHs = GenerateDH()             # fresh key pair
    state.RK, state.CKs = KDF_RK(state.RK, DH(state.DHs, state.DHr))

The recipe uses only primitives you may already know: a key-derivation function (HKDF) for the root step, HMAC for the chain step, and an authenticated cipher for the messages themselves.

Steal the Keys, Watch the Ratchet Heal

Below, Alice and Bob run a real Double Ratchet. The key derivation functions are the real ones, HKDF-SHA256 and HMAC-SHA256 (this page contains its own SHA-256 implementation, checked against a reference). Two things are simplified on purpose: the Diffie–Hellman group is a toy (integers modulo 231−12^{31}-1, easy to read and trivial to break), and the message cipher is a small HMAC-based stream cipher with an authentication tag instead of the AEAD of the specification. Never use this code to protect anything.

<p class="hint">A real Double Ratchet with toy Diffie–Hellman. HKDF-SHA256 and HMAC-SHA256 are real (hand-written SHA-256 in this page); the DH group is a 31-bit toy so you can read the numbers. Send messages in either direction (Bob can speak only after receiving one), deliver them in any order, then let Eve steal a party’s state.</p>
<div class="ctl">
  <input id="txt" type="text" maxlength="40" value="hello" placeholder="Message text" aria-label="Message text">
  <button id="sa" type="button">Alice sends</button>
  <button id="sb" type="button">Bob sends</button>
</div>
<div class="status" id="status" aria-live="polite"></div>
<div class="panels">
  <section class="pan a"><h3>Alice</h3><div id="sta"></div></section>
  <section class="pan b"><h3>Bob</h3><div id="stb"></div></section>
</div>
<h3 class="sub">Messages on the wire</h3>
<div id="wire" class="wire"></div>
<div class="btns">
  <button id="da" type="button" class="ghost">Deliver all in order</button>
  <button id="ca" type="button" class="danger">Eve steals Alice’s state</button>
  <button id="cb" type="button" class="danger">Eve steals Bob’s state</button>
  <button id="rs" type="button" class="ghost">Reset</button>
</div>
<div id="eve" class="eve" hidden></div>
:root { --fg:#1f2933; --muted:#52606d; --bg:#ffffff; --box:#eef2f6; --boxb:#cdd9e3; --acc:#1d3557;
        --ca:#2a6f97; --cb:#9c4f96; --ok:#0a7d33; --bad:#c92f3c; --chg:#fff3bf; }
@media (prefers-color-scheme: dark) {
  :root { --fg:#e4e9ee; --muted:#a3b1bf; --bg:#14181d; --box:#1e262f; --boxb:#3a4a5a; --acc:#8fb3dd;
          --ca:#6bb3e0; --cb:#d79ad2; --ok:#4cc38a; --bad:#ff7b84; --chg:#4a4216; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 4px; font-size: 14px; }
.hint { font-size: .84rem; color: var(--muted); margin: .2rem 0 .5rem; line-height: 1.45; }
.ctl { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; }
input { flex: 1 1 120px; min-width: 0; font: inherit; padding: .4rem .5rem; border: 1px solid var(--boxb); border-radius: 8px; background: var(--bg); color: var(--fg); }
button { font: 600 13px system-ui, sans-serif; padding: .42rem .75rem; border: 1px solid var(--acc); background: var(--acc); color: var(--bg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--acc); }
button.danger { background: transparent; color: var(--bad); border-color: var(--bad); }
button.mini { padding: .15rem .5rem; font-size: 12px; }
.btns { display: flex; gap: .4rem; flex-wrap: wrap; margin: .6rem 0; }
.status { margin: .5rem 0; font-size: .84rem; line-height: 1.4; min-height: 2.6em; }
.status .new { font-weight: 600; }
.status .old { color: var(--muted); font-size: .78rem; }
.panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .5rem; }
.pan { background: var(--box); border: 1px solid var(--boxb); border-radius: 10px; padding: .4rem .6rem; border-top: 3px solid var(--ca); }
.pan.b { border-top-color: var(--cb); }
h3 { margin: .1rem 0 .3rem; font-size: .95rem; }
h3.sub { margin-top: .8rem; }
.row { display: flex; flex-direction: column; padding: .12rem .2rem; border-radius: 4px; }
.row.chg { background: var(--chg); }
.k { font-size: .66rem; color: var(--muted); }
.v { font: 12px ui-monospace, monospace; word-break: break-all; }
.wire { display: flex; flex-direction: column; gap: .35rem; }
.msg { border: 1px solid var(--boxb); border-left: 4px solid var(--ca); border-radius: 8px; padding: .3rem .5rem; font-size: .8rem; }
.msg.B { border-left-color: var(--cb); }
.msg.past { opacity: .85; border-style: dashed; border-left-style: solid; }
.mh { display: flex; flex-wrap: wrap; gap: .3rem; align-items: center; }
.mm { font: 11px ui-monospace, monospace; color: var(--muted); word-break: break-all; }
.ms { font-size: .78rem; }
.ms i { font-style: normal; font-weight: 600; }
.empty { color: var(--muted); font-size: .8rem; }
.tag { font-size: .7rem; font-weight: 700; padding: .05rem .4rem; border-radius: 99px; border: 1px solid; }
.tag.ok { color: var(--ok); }
.tag.bad { color: var(--bad); }
.eve { margin-top: .4rem; border: 1px solid var(--bad); border-radius: 10px; padding: .5rem .7rem; font-size: .82rem; line-height: 1.45; }
.okline { color: var(--ok); font-weight: 600; }
.badline { color: var(--bad); font-weight: 600; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// SHA-256: round constants come from the cube roots of the first 64 primes
const PR = []; for (let n = 2; PR.length < 64; n++) { let ok = true; for (let d = 2; d * d <= n; d++) if (n % d === 0) { ok = false; break; } if (ok) PR.push(n); }
const frac = x => x - Math.floor(x);
const KC = PR.map(p => Math.floor(frac(Math.cbrt(p)) * 4294967296));
const H0 = PR.slice(0, 8).map(p => Math.floor(frac(Math.sqrt(p)) * 4294967296));
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
function sha256(bytes) {
  const l = bytes.length, total = ((l + 9 + 63) >> 6) << 6;
  const m = new Uint8Array(total); m.set(bytes); m[l] = 0x80;
  const dv = new DataView(m.buffer);
  dv.setUint32(total - 8, Math.floor(l / 536870912)); dv.setUint32(total - 4, (l << 3) >>> 0);
  const h = H0.slice(), w = new Uint32Array(64);
  for (let o = 0; o < total; o += 64) {
    for (let i = 0; i < 16; i++) w[i] = dv.getUint32(o + 4 * i);
    for (let i = 16; i < 64; i++) {
      const a = w[i - 15], b = w[i - 2];
      w[i] = (w[i - 16] + (rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3)) + w[i - 7] + (rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10))) >>> 0;
    }
    let [a, b, c, d, e, f, g, hh] = h;
    for (let i = 0; i < 64; i++) {
      const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + KC[i] + w[i]) >>> 0;
      const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
      hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
    }
    [a, b, c, d, e, f, g, hh].forEach((x, i) => { h[i] = (h[i] + x) >>> 0; });
  }
  const out = new Uint8Array(32), ov = new DataView(out.buffer);
  h.forEach((x, i) => ov.setUint32(4 * i, x));
  return out;
}
const cat = (...a) => { const o = new Uint8Array(a.reduce((s, x) => s + x.length, 0)); let p = 0; a.forEach(x => { o.set(x, p); p += x.length; }); return o; };
const utf8 = s => new TextEncoder().encode(s);
const hex = (b, n) => Array.from(b.slice(0, n || b.length)).map(x => x.toString(16).padStart(2, '0')).join('');
function hmac(key, msg) {
  if (key.length > 64) key = sha256(key);
  const k = new Uint8Array(64); k.set(key);
  return sha256(cat(k.map(x => x ^ 0x5c), sha256(cat(k.map(x => x ^ 0x36), msg))));
}
// HKDF-SHA256 (extract and expand), returning 64 bytes as two 32-byte halves
function hkdf64(salt, ikm, info) {
  const prk = hmac(salt, ikm), inf = utf8(info);
  const t1 = hmac(prk, cat(inf, Uint8Array.of(1)));
  const t2 = hmac(prk, cat(t1, inf, Uint8Array.of(2)));
  return [t1, t2];
}
// Toy Diffie–Hellman in the group of integers modulo 2^31 - 1: only for reading, never for security
const PM = 2147483647n, G = 7n;
function modexp(b, e, m) { let r = 1n; b %= m; while (e > 0n) { if (e & 1n) r = r * b % m; b = b * b % m; e >>= 1n; } return r; }
function genKey() { const priv = BigInt(2 + Math.floor(Math.random() * 2147483000)); return { priv, pub: modexp(G, priv, PM) }; }
function dh(priv, pub) { const s = modexp(pub, priv, PM); return Uint8Array.of(Number(s >> 24n) & 255, Number(s >> 16n) & 255, Number(s >> 8n) & 255, Number(s) & 255); }
// Root KDF (HKDF) and chain KDF (HMAC with the inputs 0x01 and 0x02, as in the specification)
function kdfRk(rk, dhOut) { return hkdf64(rk, dhOut, "toy-double-ratchet-rk"); }
function kdfCk(ck) { return [hmac(ck, Uint8Array.of(2)), hmac(ck, Uint8Array.of(1))]; }
// Simplified message encryption: HMAC-based keystream plus a truncated HMAC tag over header and ciphertext
function seal(mk, hdr, pt) {
  const [ke, km] = hkdf64(new Uint8Array(32), mk, "toy-double-ratchet-msg");
  const ct = utf8(pt).map((x, i) => x ^ hmac(ke, Uint8Array.of(i >> 8, i & 255))[0]);
  return { ct, tag: hmac(km, cat(utf8(hdr), ct)).slice(0, 8) };
}
function open(mk, hdr, m) {
  const [ke, km] = hkdf64(new Uint8Array(32), mk, "toy-double-ratchet-msg");
  const tag = hmac(km, cat(utf8(hdr), m.ct)).slice(0, 8);
  if (hex(tag) !== hex(m.tag)) return null;
  return new TextDecoder().decode(m.ct.map((x, i) => x ^ hmac(ke, Uint8Array.of(i >> 8, i & 255))[0]));
}
const hdrOf = m => m.dh + "|" + m.pn + "|" + m.n;
const MAX_SKIP = 50;
const cp = s => Object.assign({}, s, { skipped: new Map(s.skipped) });
const skey = (dhp, n) => dhp + ":" + n;
// Setup: both sides start from a shared secret, as if X3DH had already run
function makeParties() {
  const sk = hmac(utf8("toy-x3dh"), utf8(String(Math.random())));
  const bk = genKey(), ak = genKey();
  const [rk, cks] = kdfRk(sk, dh(ak.priv, bk.pub));
  const alice = { DHs: ak, DHr: bk.pub, RK: rk, CKs: cks, CKr: null, Ns: 0, Nr: 0, PN: 0, skipped: new Map() };
  const bob = { DHs: bk, DHr: null, RK: sk, CKs: null, CKr: null, Ns: 0, Nr: 0, PN: 0, skipped: new Map() };
  return { alice, bob };
}
function encrypt(st, pt) {
  const [ck, mk] = kdfCk(st.CKs); st.CKs = ck;
  const m = { dh: st.DHs.pub, pn: st.PN, n: st.Ns };
  st.Ns++;
  const s = seal(mk, hdrOf(m), pt); m.ct = s.ct; m.tag = s.tag;
  return { m, mk };
}
function skipKeys(s, until) {
  if (s.CKr === null) return;
  if (until - s.Nr > MAX_SKIP) throw new Error("skip");
  while (s.Nr < until) { const [ck, mk] = kdfCk(s.CKr); s.CKr = ck; s.skipped.set(skey(s.DHr, s.Nr), mk); s.Nr++; }
}
function dhRatchet(s, m) {
  s.PN = s.Ns; s.Ns = 0; s.Nr = 0; s.DHr = m.dh;
  let r = kdfRk(s.RK, dh(s.DHs.priv, s.DHr)); s.RK = r[0]; s.CKr = r[1];
  s.DHs = genKey();
  r = kdfRk(s.RK, dh(s.DHs.priv, s.DHr)); s.RK = r[0]; s.CKs = r[1];
}
function decrypt(st, m) {
  const s = cp(st), before = s.skipped.size;
  let mk = s.skipped.get(skey(m.dh, m.n)), ratcheted = false;
  if (mk) { s.skipped.delete(skey(m.dh, m.n)); }
  else {
    if (s.DHr === null || m.dh !== s.DHr) { skipKeys(s, m.pn); dhRatchet(s, m); ratcheted = true; }
    skipKeys(s, m.n);
    const r = kdfCk(s.CKr); s.CKr = r[0]; mk = r[1]; s.Nr++;
  }
  const pt = open(mk, hdrOf(m), m);
  if (pt === null) return null;
  Object.assign(st, s);
  return { pt, mk, ratcheted, stored: Math.max(0, s.skipped.size - before) };
}
// Eve starts from a copy of the stolen state and follows the public traffic exactly as the victim would
function eveReads(snap, who, log) {
  const A = cp(snap), res = {};
  for (const m of log) {
    let got = null;
    if (m.from === who) {
      if (snap.CKs && m.dh === snap.DHs.pub && m.n >= snap.Ns) {
        let ck = snap.CKs, mk = null;
        for (let i = snap.Ns; i <= m.n; i++) { const r = kdfCk(ck); ck = r[0]; mk = r[1]; }
        got = open(mk, hdrOf(m), m);
      }
    } else {
      try { got = decrypt(A, m) ? "x" : null; } catch (e) { got = null; }
    }
    res[m.id] = got !== null;
  }
  return res;
}
// Interface
const NAME = { A: "Alice", B: "Bob" };
const $ = id => document.getElementById(id);
let P, log, nextId, eve, prevRows, notes;
const labels = new Map(), cnt = { A: 0, B: 0 };
function lab(pub, who) {
  const k = String(pub);
  if (!labels.has(k)) { cnt[who]++; labels.set(k, who + cnt[who]); }
  return labels.get(k);
}
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
function init() {
  const p = makeParties();
  P = { A: p.alice, B: p.bob }; log = []; nextId = 0; eve = null; notes = ["Alice sends first: Bob has no sending chain until he receives a message."];
  labels.clear(); cnt.A = 0; cnt.B = 0; prevRows = { A: [], B: [] };
  render();
}
function note(t) { notes.unshift(t); notes = notes.slice(0, 3); }
function send(who) {
  const st = P[who];
  if (!st.CKs) { note(i18nFmt("%who% has no sending chain yet: a message from the other side must arrive first.", { who: NAME[who] })); render(); return; }
  const txt = $("txt").value || "hello";
  const r = encrypt(st, txt);
  const m = Object.assign(r.m, { id: nextId++, from: who, to: who === "A" ? "B" : "A", pt: txt, status: "flight" });
  lab(m.dh, who);
  log.push(m);
  note(i18nFmt("%who% sent message #%id% using message key %mk%…", { who: NAME[who], id: m.id, mk: hex(r.mk, 4) }));
  render();
}
function deliver(id) {
  const m = log.find(x => x.id === id);
  if (!m || m.status !== "flight") return;
  let r = null;
  try { r = decrypt(P[m.to], m); } catch (e) { r = null; }
  if (!r) { m.status = "fail"; note(i18nFmt("%who% could not decrypt message #%id%.", { who: NAME[m.to], id: m.id })); return; }
  m.status = "done"; m.mkHex = hex(r.mk, 4);
  let t = i18nFmt("%who% decrypted #%id%: “%pt%” (message key %mk%…).", { who: NAME[m.to], id: m.id, pt: m.pt, mk: m.mkHex });
  if (r.ratcheted) t += " " + i18nFmt("DH ratchet step: %who% derived a new root key, a new receiving chain and, with a fresh key pair, a new sending chain.", { who: NAME[m.to] });
  if (r.stored) t += " " + i18nFmt("Stored %k% skipped message key(s) for messages still in flight.", { k: r.stored });
  note(t);
}
function deliverAll() { log.filter(m => m.status === "flight").forEach(m => deliver(m.id)); render(); }
function compromise(who) {
  eve = { who, snap: cp(P[who]), atId: nextId };
  note(i18nFmt("Eve copied %who%’s complete state. Keep chatting to see what she can and cannot read.", { who: NAME[who] }));
  render();
}
function panel(who) {
  const s = P[who], o = who === "A" ? "B" : "A";
  const rows = [
    ["Own ratchet key (DHs)", lab(s.DHs.pub, who) + " · " + s.DHs.pub],
    ["Peer ratchet key (DHr)", s.DHr === null ? "—" : lab(s.DHr, o) + " · " + s.DHr],
    ["Root key RK", hex(s.RK, 6)],
    ["Sending chain key CKs", s.CKs ? hex(s.CKs, 6) : "—"],
    ["Receiving chain key CKr", s.CKr ? hex(s.CKr, 6) : "—"],
    ["Counters Ns / Nr / PN", s.Ns + " / " + s.Nr + " / " + s.PN],
    ["Stored skipped keys", String(s.skipped.size)]
  ];
  const html = rows.map((r, i) => {
    const chg = prevRows[who][i] !== undefined && prevRows[who][i] !== r[1] ? " chg" : "";
    return '<div class="row' + chg + '"><span class="k">' + r[0] + '</span><span class="v">' + esc(r[1]) + "</span></div>";
  }).join("");
  prevRows[who] = rows.map(r => r[1]);
  $("st" + who.toLowerCase()).innerHTML = html;
}
function render() {
  panel("A"); panel("B");
  const reads = eve ? eveReads(eve.snap, eve.who, log) : null;
  $("wire").innerHTML = log.length ? log.map(m => {
    const head = i18nFmt("#%id% %from% → %to%", { id: m.id, from: NAME[m.from], to: NAME[m.to] });
    const meta = i18nFmt("ratchet key %dh% · n=%n% · pn=%pn% · ciphertext %ct%…", { dh: lab(m.dh, m.from), n: m.n, pn: m.pn, ct: hex(m.ct, 6) });
    let st = m.status === "flight" ? "in flight" : m.status === "fail" ? "rejected" : i18nFmt("delivered: “%pt%”", { pt: esc(m.pt) });
    let btn = m.status === "flight" ? '<button class="mini" data-id="' + m.id + '">Deliver</button>' : "";
    let ev = "";
    if (reads) ev = reads[m.id] ? '<span class="tag bad">Eve reads it</span>' : '<span class="tag ok">Eve cannot</span>';
    const past = eve && m.id < eve.atId ? " past" : "";
    return '<div class="msg ' + m.from + past + '"><div class="mh"><b>' + esc(head) + "</b> " + ev + " " + btn + '</div><div class="mm">' + esc(meta) + '</div><div class="ms">' + st + "</div></div>";
  }).join("") : '<div class="empty">No messages yet.</div>';
  $("wire").querySelectorAll("button.mini").forEach(b => b.addEventListener("click", () => { deliver(Number(b.dataset.id)); render(); }));
  $("status").innerHTML = notes.map((t, i) => '<div class="' + (i ? "old" : "new") + '">' + esc(t).replace(/“(.*?)”/g, "“<i>$1</i>”") + "</div>").join("");
  renderEve(reads);
}
function renderEve(reads) {
  const box = $("eve");
  if (!eve) { box.hidden = true; return; }
  box.hidden = false;
  const past = log.filter(m => m.id < eve.atId), after = log.filter(m => m.id >= eve.atId);
  const safe = past.filter(m => !reads[m.id]).length, rd = after.filter(m => reads[m.id]).length;
  let t = "<b>" + esc(i18nFmt("Eve copied %who%’s state after %n% message(s) had been sent.", { who: NAME[eve.who], n: eve.atId })) + "</b>";
  if (past.length) t += "<div>" + esc(i18nFmt("Earlier messages Eve cannot read: %safe% of %total%.", { safe, total: past.length })) + "</div>";
  if (past.length > safe) t += "<div>" + esc("Messages that were still in flight are exposed: their keys had not been used yet.") + "</div>";
  if (after.length) {
    t += "<div>" + esc(i18nFmt("Messages sent since: Eve can read %r% of %t%.", { r: rd, t: after.length })) + "</div>";
    let lastRead = -1; after.forEach((m, i) => { if (reads[m.id]) lastRead = i; });
    const heal = after[lastRead + 1];
    t += heal ? '<div class="okline">' + esc(i18nFmt("Healed: from message #%id% on, Eve is locked out. The DH ratchet mixed in a fresh private key she never saw.", { id: heal.id })) + "</div>"
      : '<div class="badline">' + esc("Not healed yet: Eve still follows the chains. A reply from the other side triggers the DH ratchet step that locks her out.") + "</div>";
  }
  box.innerHTML = t;
}
$("sa").addEventListener("click", () => send("A"));
$("sb").addEventListener("click", () => send("B"));
$("da").addEventListener("click", deliverAll);
$("ca").addEventListener("click", () => compromise("A"));
$("cb").addEventListener("click", () => compromise("B"));
$("rs").addEventListener("click", init);
init();
Result
Console

Three experiments worth running:

  1. Watch the ratchets turn. Let Alice send two messages and deliver them. Bob's panel highlights what changed: the receiving chain key advanced twice, one step per message. Now let Bob reply: the highlighted own ratchet key shows a DH ratchet step, with a new root key and two new chains.
  2. Break the order. Let Alice send three messages and deliver #2 first. Bob must skip message #1: its key is derived and stored (see the stored skipped keys counter), so that message can still be read when it finally arrives.
  3. Steal, then heal. Exchange a few messages, press Eve steals Bob’s state, and keep talking. Eve follows the traffic with a copy of Bob's state and tries to decrypt every ciphertext for real (the authentication tag must verify). Earlier messages are safe. Messages in the same chain are exposed. Then, once Alice has written with a new ratchet key and Bob has received it and replied, Eve is locked out: the summary box reports where the ratchet healed.

Costs and Guarantees

The two key derivations. In the specification, the chain step uses HMAC with two fixed one-byte inputs, and the root step uses HKDF with the root key as salt:

(cki+1,  mki)=(HMAC(cki,0x02), HMAC(cki,0x01))(ck_{i+1},\; mk_i) = \big(\mathrm{HMAC}(ck_i, \mathtt{0x02}),\ \mathrm{HMAC}(ck_i, \mathtt{0x01})\big)

(rk′,  ck)=HKDF(salt=rk, ikm=DH(a,B))(rk',\; ck) = \mathrm{HKDF}\big(\text{salt}=rk,\ \text{ikm}=\mathrm{DH}(a, B)\big)

Here aa is your current private ratchet key and BB the peer's latest public key.

Cost per message. Let MM denote the constant MAX_SKIP, the largest gap of lost or delayed messages the receiver agrees to bridge.

  • Symmetric step: two HMAC evaluations, O(1)O(1) time and space, in the sender and in the receiver.
  • DH ratchet step (whenever the direction of the conversation changes): two root-key derivations and three elliptic-curve scalar multiplications with X25519 (one key agreement, one new key pair, one more key agreement), still O(1)O(1).
  • Messages arriving late or lost: a gap of gg messages costs gg extra chain steps and gg stored message keys, so O(g)O(g) time and space. A receiver can be forced to do at most 2M+12M+1 chain steps per message, since it may skip up to MM keys in the old chain and up to MM in the new one. Larger gaps are rejected.
  • State: a few 32-byte keys, three counters (NsN_s, NrN_r, PNPN), and the skipped-key store of at most O(M)O(M) per chain. It does not grow with the length of the conversation.
  • Overhead per message: one public ratchet key (32 bytes for X25519) and two counters in the header, plus the authentication tag.

What is guaranteed.

  • Forward secrecy: after a message key is used, the receiver deletes it. Knowing a chain key cktck_t reveals mkimk_i only for i≥ti \ge t; going backwards means inverting HMAC, which would break its pseudorandomness. Keys of earlier chains additionally depended on private ratchet keys that were erased.
  • Post-compromise security: suppose an attacker obtains the whole state of one party but stays passive afterwards. After the victim receives a message carrying a new ratchet key and replies, the new root key mixes in DH(a′,B)\mathrm{DH}(a', B) where a′a' is a fresh private key the attacker never saw. Recovering it from public values is the Diffie–Hellman problem. Healing therefore takes about one round trip of alternating messages.
  • Formal analysis. Cohn-Gordon, Cremers, Dowling, Garratt and Stebila analysed X3DH and the Double Ratchet as a multi-stage key exchange (IEEE EuroS&P 2017). They proved these properties in their security model and reported no major design flaws.

What is not guaranteed.

  • Messages in flight. Keys that exist but have not been used yet (skipped keys, or the next keys of the current chain) are exposed by a stolen state. Try it in the demo.
  • A permanent attacker. If the attacker keeps stealing the state after every step, or controls the randomness that generates the fresh keys, no healing happens.
  • Deletion is assumed. Forward secrecy only holds if the implementation really erases old keys, which is hard on phones with backups, swap files and snapshots.
  • Metadata. Who talks to whom, when, and how much is outside the scope of the ratchet.

Where It Matters

  • Signal and WhatsApp. Open Whisper Systems announced on 5 April 2016 that WhatsApp's integration of the Signal protocol was complete for all messages, group chats, attachments and calls; at that time WhatsApp counted about a billion users. Other products adopted the same protocol for private conversations, and its design has become the reference for end-to-end encrypted messaging.
  • Session setup: X3DH, then PQXDH. The Double Ratchet needs an initial shared secret. X3DH derives it from a mix of long-term and one-time elliptic-curve Diffie–Hellman agreements, even if the recipient is offline. On 19 September 2023 Signal announced PQXDH, which adds a post-quantum key encapsulation (CRYSTALS-Kyber, now standardized as ML-KEM) so that an attacker who records traffic today cannot decrypt it with a future quantum computer. See Kyber and Dilithium.
  • A post-quantum ratchet: SPQR and the Triple Ratchet. PQXDH protects the start of a session. On 2 October 2025 Signal announced the Sparse Post-Quantum Ratchet (SPQR), which keeps renewing keys with ML-KEM during the conversation. Its output is mixed with the classical Double Ratchet in what Signal calls the Triple Ratchet, so the keys stay secure unless an attacker breaks both the elliptic-curve exchange and ML-KEM. Because ML-KEM keys and ciphertexts are large (1184 and 1088 bytes for ML-KEM-768), the key material is split into chunks sent across several messages, using erasure coding.
  • Groups: MLS. A pairwise ratchet between every two members does not scale to large groups. The IETF's Messaging Layer Security protocol, published as RFC 9420 in 2023, keeps group keys in a tree (TreeKEM) so that updating a member's key costs O(log⁡n)O(\log n) in a group of nn members, while keeping forward secrecy and post-compromise security.
  • Trusting the keys. The ratchet protects the content once you know whose public key you are talking to. Checking that the server has not swapped those keys is a separate problem: see key transparency.

Conclusion

The Double Ratchet turns a single shared secret into an endless supply of disposable keys. A hash-based ratchet makes yesterday's keys impossible to recover; a Diffie–Hellman ratchet makes tomorrow's keys unreachable for an attacker who only holds a stale copy of the state.

What makes it elegant is how little it asks for: a few HMAC calls per message, an occasional Diffie–Hellman agreement, and a bounded pile of skipped keys. And as the 2023 and 2025 upgrades show, the structure is flexible enough to absorb post-quantum cryptography without changing the idea: keep the keys moving, and make every break-in short-lived.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/double-ratchet/Content licensed under CC BY-NC 4.0.