The same ECDH exchange, applied to a specific purpose
"Run handshake" starts exactly like the ECDH tool: independent P-256 key pairs for Client and Server, each deriving the identical shared secret via scalar multiplication. What's different here is everything that happens to that shared secret afterward — TLS 1.3 never uses a raw ECDH output as an encryption key directly.
This is also what the Client and Server log panels above show happening, line by line: key generation, the key_share exchange, then each side computing independently from there.
const client = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const server = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const clientPub = await crypto.subtle.exportKey("raw", client.publicKey);
const serverPub = await crypto.subtle.exportKey("raw", server.publicKey);
// these two exports are the only bytes that "cross the wire" — logged as
// ClientHello/ServerHello key_share in the panels aboveIdentical to the ECDH tool's key generation — this tool is that same exchange, with everything below added on top of its output.
Binding the secret to this exact handshake, via HKDF
The shared secret is imported as HKDF input key material and run through crypto.subtle.deriveKey with the HKDF algorithm — real HKDF (RFC 5869), the same construction the key derivation functions module covers, not a simulation. Its info parameter carries a hash of this simulated handshake's own transcript (a running SHA-256 over the Hello messages both sides just exchanged), which is what cryptographically ties the resulting key to this specific connection — reusing the identical shared secret in a different handshake, with a different transcript, would derive a completely different key.
const transcriptHash = await crypto.subtle.digest("SHA-256", transcriptBytes);
const clientSecret = await crypto.subtle.deriveBits(
{ name: "ECDH", public: server.publicKey },
client.privateKey,
256
);
const clientIkm = await crypto.subtle.importKey("raw", clientSecret, "HKDF", false, ["deriveKey"]);
const clientKey = await crypto.subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: transcriptHash },
clientIkm,
{ name: "AES-GCM", length: 256 }, // the output type this key material becomes
true,
["encrypt", "decrypt"]
);salt is empty here — real TLS 1.3 uses a running chain of salts across early/handshake/application secrets (covered in the TLS module), which this tool collapses to keep the mechanism visible. info is the one place the transcript hash enters the computation.
Extract, then Expand
HKDF is two stages folded into one Web Crypto call: Extract pools the shared secret's entropy into a fixed-size pseudorandom key, and Expand stretches that into an output of exactly the length requested — 256 bits here, sized for the AES-GCM key it becomes. Real TLS 1.3 runs this same two-stage process several times over the course of a handshake (early, handshake, and application traffic secrets, each further split into client-write and server-write keys); this tool collapses that down to one derivation to keep the core mechanism visible.
// Same call as above. WebCrypto's HKDF never exposes Extract's
// pseudorandom key (PRK) on its own — deriveKey() runs Extract then
// Expand internally and only ever returns the final Expand output.
const clientKey = await crypto.subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: transcriptHash },
clientIkm,
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);Node's crypto.hkdfSync() has the same shape — one call, no access to the intermediate PRK. That's a deliberate RFC 5869 design choice, not a WebCrypto limitation: Extract's output is only ever meant to feed Expand, not to be used directly.
The derived key encrypts a real record
"Encrypt application data" takes the HKDF output directly as an AES-256-GCM key — no separate key-generation step — and encrypts your message exactly as the AES-GCM tool does: a random IV, ciphertext, and a 16-byte authentication tag.
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
clientKey, // the CryptoKey deriveKey() returned — never exported to raw bytes first
new TextEncoder().encode(plaintext)
);clientKey came straight out of deriveKey() as a CryptoKey object, already scoped to ["encrypt", "decrypt"] — encrypt() uses it directly, the same way it would use any other AES-GCM key.
Why the server lands on the identical key, independently
"Server derives & decrypts" runs the mirrored computation: the server's own ECDH scalar multiplication (landing on the same point, since ECDH is commutative), the same transcript hash (both sides hashed the identical exchanged messages), and the identical HKDF call — producing a bit-for-bit identical traffic key with no key ever having crossed the wire. That's the entire trick: everything two sides need to agree on a key is either public (the transcript) or independently derivable (the shared secret), never transmitted.
const serverSecret = await crypto.subtle.deriveBits(
{ name: "ECDH", public: client.publicKey }, // mirror image of the client's call
server.privateKey,
256
);
const serverIkm = await crypto.subtle.importKey("raw", serverSecret, "HKDF", false, ["deriveKey"]);
const serverKey = await crypto.subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: transcriptHash }, // same transcriptHash both sides computed
serverIkm,
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
serverKey,
ciphertext
);Every input to this chain on the server's side — its own private key, the client's public key, and the transcript hash — is either something only the server holds or something publicly exchanged. Nothing here required receiving a key from the client.