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 filesfunction 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 onefunction 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")}`);} import { createPublicKey, diffieHellman, generateKeyPairSync } from "node:crypto"; // In a live exchange each side makes a fresh key pair and sends only its public keyconst alice = generateKeyPairSync("x25519"); // or ("ec", { namedCurve: "P-256" })const bob = generateKeyPairSync("x25519"); // What travels over the wire: the public keys, here as SPKI PEMconst 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
createPrivateKeyandcreatePublicKeyread the PEM files; the curve comes from the key, so oneagreefunction serves X25519 and P-256.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.hkdfSync("sha256", secret, salt, info, 32)derives the key. Theinfostring names what the key is for, so a key derived for one purpose cannot be confused with another derived from the same secret.- 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. hkdfSyncreturns anArrayBuffer, not aBuffer:hkdfSync(…).toString("hex")prints[object ArrayBuffer]. Wrap it inBuffer.fromas 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
diffieHellmanthrowERR_OSSL_FAILED_DURING_DERIVATION. Catch it and reject the peer instead of retrying with another key.
Related recipes
- How to derive a shared key with ECDH and HKDF in WebCrypto
- How to derive a shared key with ECDH and HKDF in Python
- How to derive a shared key with ECDH and HKDF in Go
- How to derive a shared key with ECDH and HKDF with OpenSSL
- How to sign and verify Ed25519 signatures in Node.js
- How to generate an ECDSA key pair in Node.js