Skip to content
ecdsa.com

ECDH key agreement · Node.js

How to derive a shared key with ECDH and HKDF in Node.js

Node.js derives a shared key with ECDH — X25519 or P-256 — using only node:crypto: crypto.diffieHellman combines your private key with the peer's public key, and crypto.hkdfSync turns the raw shared secret into a key. The recipe uses the published RFC 7748 and RFC 5903 test keys, so the printed secrets can be checked against the RFCs.

Tested with Node.js 24.22.0 (node:crypto, OpenSSL 3.5.8).

agree.mjs — X25519 and P-256 agreement from PEM files, then HKDF-SHA256
import { readFileSync } from "node:fs";
import { createPrivateKey, createPublicKey, diffieHellman, hkdfSync } from "node:crypto";
// The same call covers X25519 and P-256: the curve comes from the PEM files
function agree(privateKeyFile, peerPublicKeyFile) {
return diffieHellman({
privateKey: createPrivateKey(readFileSync(privateKeyFile)),
publicKey: createPublicKey(readFileSync(peerPublicKeyFile)),
});
}
// The raw shared secret is not a key yet: HKDF-SHA256 turns it into one
function deriveKey(sharedSecret) {
return Buffer.from(hkdfSync("sha256", sharedSecret, Buffer.alloc(0), "AES-256-GCM key v1", 32));
}
for (const [curve, mine, theirs] of [
["X25519", "alice-x25519.pem", "bob-x25519.pub.pem"],
["P-256", "alice-p256.pem", "bob-p256.pub.pem"],
]) {
const shared = agree(mine, theirs);
console.log(`${curve} shared secret: ${shared.toString("hex")}`);
console.log(`${curve} derived key: ${deriveKey(shared).toString("hex")}`);
}
a live exchange: fresh key pairs, only public keys cross the wire
import { createPublicKey, diffieHellman, generateKeyPairSync } from "node:crypto";
// In a live exchange each side makes a fresh key pair and sends only its public key
const alice = generateKeyPairSync("x25519"); // or ("ec", { namedCurve: "P-256" })
const bob = generateKeyPairSync("x25519");
// What travels over the wire: the public keys, here as SPKI PEM
const fromAlice = alice.publicKey.export({ type: "spki", format: "pem" });
const fromBob = bob.publicKey.export({ type: "spki", format: "pem" });
const aliceSecret = diffieHellman({ privateKey: alice.privateKey, publicKey: createPublicKey(fromBob) });
const bobSecret = diffieHellman({ privateKey: bob.privateKey, publicKey: createPublicKey(fromAlice) });
console.log("both sides agree:", aliceSecret.equals(bobSecret));

How it works

  1. createPrivateKey and createPublicKey read the PEM files; the curve comes from the key, so one agree function serves X25519 and P-256.
  2. diffieHellman({ privateKey, publicKey }) returns the raw shared secret — 32 bytes for both curves here. Both sides compute the same value from their own private key and the other side's public key.
  3. hkdfSync("sha256", secret, salt, info, 32) derives the key. The info string names what the key is for, so a key derived for one purpose cannot be confused with another derived from the same secret.
  4. The second snippet shows the real protocol shape: each side generates a key pair, sends the SPKI public key, and both arrive at the same secret.

Gotchas

  • Never use the shared secret directly as an AES key. It is a curve coordinate, not uniformly random bytes, and it is the same for every purpose; run it through HKDF with a purpose-specific info.
  • hkdfSync returns an ArrayBuffer, not a Buffer: hkdfSync(…).toString("hex") prints [object ArrayBuffer]. Wrap it in Buffer.from as above.
  • Both keys must be of the same type: a P-256 private key with an X25519 public key throws ERR_CRYPTO_INCOMPATIBLE_KEY (Incompatible key types for Diffie-Hellman: ec and x25519).
  • A malicious all-zero X25519 public key makes diffieHellman throw ERR_OSSL_FAILED_DURING_DERIVATION. Catch it and reject the peer instead of retrying with another key.

Related recipes