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−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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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();
<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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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();
Comments
Loading comments...