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.
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 BobThree 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.
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.
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.
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 digestNo 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.
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 byteEach 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.