const pemToBytes = (pem) => Uint8Array.from(atob(pem.replace(/-----[^-]+-----|\s+/g, "")), (c) => c.charCodeAt(0)); // algorithm: { name: "X25519" } or { name: "ECDH", namedCurve: "P-256" }async function sharedSecret(privateKeyPem, peerPublicKeyPem, algorithm) { const privateKey = await crypto.subtle.importKey("pkcs8", pemToBytes(privateKeyPem), algorithm, false, ["deriveBits"]); // A peer's public key gets no usages at all: [] (["deriveBits"] is a SyntaxError) const publicKey = await crypto.subtle.importKey("spki", pemToBytes(peerPublicKeyPem), algorithm, false, []); return new Uint8Array(await crypto.subtle.deriveBits({ name: algorithm.name, public: publicKey }, privateKey, 256));}const hkdfParams = { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), // a required member, but it may be empty info: new TextEncoder().encode("AES-256-GCM key v1"),}; // The derived bytes — what the other languages printasync function deriveKeyBytes(secret) { const hkdfKey = await crypto.subtle.importKey("raw", secret, "HKDF", false, ["deriveBits"]); return new Uint8Array(await crypto.subtle.deriveBits(hkdfParams, hkdfKey, 256));}// In the browser, derive the AES key directly and keep its bytes out of JavaScriptasync function deriveAesKey(secret) { const hkdfKey = await crypto.subtle.importKey("raw", secret, "HKDF", false, ["deriveKey"]); return crypto.subtle.deriveKey(hkdfParams, hkdfKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);}How it works
- The private key imports with
["deriveBits"]; the peer's public key imports with an empty usage list[]. deriveBits({ name, public: peerKey }, privateKey, 256)returns the 32-byte shared secret. For P-256 the algorithm is"ECDH"withnamedCurve: "P-256"; for X25519 it is"X25519".- The secret is imported as an
"HKDF"key, andderiveBitswith{ name: "HKDF", hash, salt, info }produces the key bytes the other languages print. deriveKeywith{ name: "AES-GCM", length: 256 }yields the same key as aCryptoKeythat page scripts can use but never read; the run checks that it decrypts what the raw bytes encrypt.
Gotchas
- Giving the peer's public key
["deriveBits"]throwsSyntaxError: Unsupported key usage for ECDH key(orfor X25519 key). Public keys take no usages in ECDH. saltis a required member ofHkdfParams: leaving it out throwsTypeError('salt' is required in 'HkdfParams'). An emptyUint8Arraygives the RFC 5869 default.- Mixing curves — an X25519 public key with an ECDH P-256 private key — rejects with
InvalidAccessError: key algorithm mismatch. - Older browsers reject
"X25519"withNotSupportedError. Where you must support them, P-256 ECDH is the portable choice, or fall back to a library such as @noble/curves.