← Back to the tool

Protocols · Under the hood

X3DH: key agreement while offline

What each button in the tool actually computes, step by step — with the real formulas.

Why one Diffie-Hellman isn't enough here

The ECDH tool's exchange needs both sides online to swap public keys in real time. X3DH's trick is precomputing several DH values from keys Bob published in advance, so Alice — the sender — can complete every DH computation herself, without Bob's participation at that moment at all.

Bob publishes a bundle of real ECDH key pairs, then goes offline
async function genEcdh() {
  return crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
}

const ik = await genEcdh();  // identity key
const spk = await genEcdh(); // signed prekey
const opk = await genEcdh(); // one-time prekey

const ikPub = await crypto.subtle.exportKey("spki", ik.publicKey);
const spkPub = await crypto.subtle.exportKey("spki", spk.publicKey);
const opkPub = await crypto.subtle.exportKey("spki", opk.publicKey);
// only ikPub, spkPub, opkPub get "published" — the private keys stay with Bob

Three ordinary ECDH key pairs — X3DH's novelty isn't in how any single key pair is generated, it's in publishing several of them ahead of time.

Four DH computations, folded into one secret

"Alice fetches the bundle" runs four separate ECDH computations, mixing her own identity and ephemeral keys with Bob's published identity key, signed prekey, and one-time prekey.

Alice's four deriveBits() calls, entirely offline for Bob
async function dh(privateKey, publicKey) {
  return crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256);
}

const ik = await genEcdh(); // Alice's own identity key
const ek = await genEcdh(); // Alice's ephemeral key, fresh for this session

const dh1 = await dh(ik.privateKey, bobKeys.spk.publicKey);
const dh2 = await dh(ek.privateKey, bobKeys.ik.publicKey);
const dh3 = await dh(ek.privateKey, bobKeys.spk.publicKey);
const dh4 = await dh(ek.privateKey, bobKeys.opk.publicKey);

Every one of these four calls only needs Bob's public keys, already sitting in his published bundle — none of them wait on Bob being online.

DH1=IKA×SPKBDH2=EKA×IKBDH3=EKA×SPKBDH4=EKA×OPKBDH_1 = IK_A \times SPK_B \qquad DH_2 = EK_A \times IK_B \qquad DH_3 = EK_A \times SPK_B \qquad DH_4 = EK_A \times OPK_B

Combining them: a simplified stand-in for HKDF

This tool concatenates the four raw DH outputs and hashes the result with SHA-256 to produce a single shared secret. Real X3DH runs that same concatenation through HKDF (covered in the key derivation functions module) instead of a plain hash, to get a properly uniform, arbitrary-length key rather than exactly one SHA-256 digest — a simplification made here for clarity, not a difference in the core idea.

Concatenate, then hash — not the real X3DH KDF, but the same shape
function concat(buffers) {
  const total = buffers.reduce((n, b) => n + b.byteLength, 0);
  const out = new Uint8Array(total);
  let offset = 0;
  for (const b of buffers) { out.set(new Uint8Array(b), offset); offset += b.byteLength; }
  return out;
}

const joined = concat([dh1, dh2, dh3, dh4]);
const secret = await crypto.subtle.digest("SHA-256", joined);
// real X3DH: HKDF(salt = 0, ikm = joined, info = "X3DH") instead of a plain digest

No separate HKDF call here — this tool substitutes one digest() for clarity, which the body text above flags explicitly as a simplification.

Why Bob can compute the identical secret, later

When Bob comes back online, he runs the mirrored computation — his private keys against Alice's public keys, in the same four combinations — and lands on the same four DH values Alice did, because ECDH is commutative: a·(bG) and b·(aG) are the same point, regardless of which side's private key did the multiplying. That's the exact property the ECDH tool demonstrates directly.

Bob's mirrored deriveBits() calls, run whenever he comes back online
const dh1 = await dh(bobKeys.spk.privateKey, aliceKeys.ik.publicKey);
const dh2 = await dh(bobKeys.ik.privateKey, aliceKeys.ek.publicKey);
const dh3 = await dh(bobKeys.spk.privateKey, aliceKeys.ek.publicKey);
const dh4 = await dh(bobKeys.opk.privateKey, aliceKeys.ek.publicKey);
// same concat() + digest("SHA-256", ...) as Alice's side
const secret = await combine([dh1, dh2, dh3, dh4]);
// secret === Alice's secret, byte for byte

Each pair is flipped — Bob's private key against Alice's public key, where Alice used her private key against Bob's public key — which is exactly what makes the two sides land on the same point.

Now try it

Back to X3DH: key agreement while offline →

Simulate Bob publishing a key bundle and going offline, then Alice computing a shared secret from it anyway — the mechanism behind Signal's first-message problem.