Applied Cryptography for Developers Module 10 – Public-key cryptography
Key agreement with X25519 and ECDH
X25519 and ECDH key agreement in Web Crypto, checked against RFC 7748 and RFC 5903 vectors, then session keys and a test of who you actually agreed with.
What you will learn
- Run X25519 and ECDH key agreement in Web Crypto and check both against RFC test vectors
- Derive separate session keys from a shared secret with HKDF and both public keys
- Explain why key agreement alone does not say who you agreed with, and test a handshake with a relay in the path
- Use ephemeral keys for forward secrecy and refuse peer values that give an all-zero secret
Before you start
What you’ll learn · Before you start
What you will learn
- Run X25519 and ECDH key agreement in Web Crypto and check both against RFC test vectors
- Derive separate session keys from a shared secret with HKDF and both public keys
- Explain why key agreement alone does not say who you agreed with, and test a handshake with a relay in the path
- Use ephemeral keys for forward secrecy and refuse peer values that give an all-zero secret
Before you start
On this page
Two phones that have never met must end up with the same 32-byte key over a network that anyone can read. X25519: The key-agreement function of RFC 7748 on Curve25519. Private keys, public keys and the shared secret are all 32 bytes, and any 32 bytes are accepted as a peer's public value. does it with two 32-byte public keys: each side combines the other’s public key with its own private key and gets the same 32 bytes, which a watcher who holds both public keys cannot compute.
This lesson runs key agreement: Two parties each combine their own private key with the other's public key and arrive at the same shared secret without sending it, as Diffie-Hellman, ECDH and X25519 do. A KDF then turns that secret into keys. More in “Public-key cryptography: RSA and elliptic curves” in Web Crypto on Node.js 24, for X25519 and for ECDH: Elliptic-curve Diffie-Hellman: each side multiplies the other side's public point by its own private number and both reach the same point. On P-256 the shared secret is its 32-byte x-coordinate. on P-256, and checks both against the published vectors of RFC 7748 and RFC 5903. Then it deals with what the raw secret still lacks: a key for each direction, a way to know who the other side is, and keys that are used once, so that a later leak opens no recorded session.
Two public keys give both sides the same 32 bytes
Alice and Bob have only a public channel. Each picks a private number, a for Alice and b for Bob, and publishes a public key: the private number times a fixed base point G of a curve, A = a·G and B = b·G. Alice multiplies Bob’s public key by her private number, Bob multiplies Alice’s by his, and because the order of the two multiplications does not matter, both reach the same point:
In words: the shared secret K is the private number of one side times the public key of the other. In the test vector of RFC 7748, Alice’s public key starts 8520f009…, Bob’s starts de9edb7d…, and both sides arrive at the 32 bytes 4a5d9d5b…1e161742. Step through it, and watch what the third party in the middle holds:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Two private keys. Alice and Bob each pick 32 random bytes and keep them. In the RFC 7748 vector, Alice's start 77076d0a… and Bob's start 5dab087e…. Neither is ever sent.
- Alice sends her public key. Alice computes X25519(a, 9), her private key times the base point, and sends the 32-byte result. Anyone watching reads it too, and that does no harm.
- Bob sends his. Bob does the same with his private key and sends de9edb7d…. Each side now holds its own private key and the other side's public key.
- Alice computes K. Alice applies X25519 to her private key and Bob's public key. The result is 32 bytes, 4a5d9d5b… up to …1e161742.
- Bob arrives at the same bytes. Bob applies X25519 to his private key and Alice's public key and gets the same 32 bytes. K itself never crossed the network.
- What the watcher holds. The watcher holds two public keys and no private key. Working out K from them is the elliptic-curve Diffie-Hellman problem, which takes about 2¹²⁶ steps on this curve with the best known methods.
A watcher holds A and B and no private number. Working K out from them is the Diffie-Hellman problem on the curve, which takes about 2¹²⁶ steps on Curve25519 with the best known methods, a level RFC 7748 calls slightly under the standard 128 bits. Key agreement with X25519 never sends a secret: it sends two public values and lets each side compute the shared secret alone.
Checkpoint
Check: How two public keys give one secret
Both curves reproduce the vectors of their RFCs
Web Crypto agrees keys with deriveBits: give it your private key and the other side’s public key and it returns the shared secret. The program feeds it the keys of RFC 7748 section 6.1 for X25519 and of RFC 5903 section 8.1 for ECDH on P-256, once from each side, and then runs a fresh exchange of each kind:
// Key agreement in Web Crypto on Node.js 24: X25519 against the test vector of RFC 7748 section 6.1, ECDH on P-256 against
// the vector of RFC 5903 section 8.1, a point that is not on the curve, and a fresh exchange of each kind between two
// generated key pairs. Only the published keys are fixed, so every run prints the same lines.
const { subtle } = globalThis.crypto;
const unhex = (h) => Uint8Array.from(Buffer.from(h.replace(/\s+/g, ''), 'hex'));
const hex = (b) => Buffer.from(b).toString('hex');
const b64u = (b) => Buffer.from(b).toString('base64url');
const short = (b) => `${hex(b).slice(0, 8)}…${hex(b).slice(-8)}`;
// Each side combines its own private key with the other side's public key; deriveBits returns the shared secret.
const agree = async (alg, mine, theirs) => new Uint8Array(await subtle.deriveBits({ ...alg, public: theirs }, mine, 256));
// ---- X25519, RFC 7748 section 6.1 ----------------------------------------------------------------------------------
const x25519 = { name: 'X25519' };
const alice = { d: '77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a', x: '8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a' };
const bob = { d: '5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb', x: 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f' };
const rfcK = '4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742';
const privateKey = (p) => subtle.importKey('jwk', { kty: 'OKP', crv: 'X25519', d: b64u(unhex(p.d)), x: b64u(unhex(p.x)) }, x25519, false, ['deriveBits']);
const publicKey = (p) => subtle.importKey('raw', unhex(p.x), x25519, true, []);
const kAlice = await agree(x25519, await privateKey(alice), await publicKey(bob));
const kBob = await agree(x25519, await privateKey(bob), await publicKey(alice));
console.log('X25519, RFC 7748 section 6.1');
console.log(` Alice's public key ${short(unhex(alice.x))} (${unhex(alice.x).length} bytes)`);
console.log(` Bob's public key ${short(unhex(bob.x))} (${unhex(bob.x).length} bytes)`);
console.log(` Alice computes ${short(kAlice)} (${kAlice.length} bytes)`);
console.log(` Bob computes ${short(kBob)}`);
console.log(` both equal the RFC's K: ${hex(kAlice) === rfcK && hex(kBob) === rfcK}`);
// ---- ECDH on P-256, RFC 5903 section 8.1 ---------------------------------------------------------------------------
const ecdh = { name: 'ECDH', namedCurve: 'P-256' };
const initiator = { d: 'C88F01F5 10D9AC3F 70A292DA A2316DE5 44E9AAB8 AFE84049 C62A9C57 862D1433', x: 'DAD0B653 94221CF9 B051E1FE CA5787D0 98DFE637 FC90B9EF 945D0C37 72581180', y: '5271A046 1CDB8252 D61F1C45 6FA3E59A B1F45B33 ACCF5F58 389E0577 B8990BB3' };
const responder = { d: 'C6EF9C5D 78AE012A 011164AC B397CE20 88685D8F 06BF9BE0 B283AB46 476BEE53', x: 'D12DFB52 89C8D4F8 1208B702 70398C34 2296970A 0BCCB74C 736FC755 4494BF63', y: '56FBF3CA 366CC23E 8157854C 13C58D6A AC23F046 ADA30F83 53E74F33 039872AB' };
const rfcGirx = 'D6840F6B 42F6EDAF D13116E0 E1256520 2FEF8E9E CE7DCE03 812464D0 4B9442DE'.replace(/\s+/g, '').toLowerCase();
const point = (p) => Uint8Array.from([4, ...unhex(p.x), ...unhex(p.y)]); // 0x04, then x and y: 65 bytes
const ecPrivate = (p) => subtle.importKey('jwk', { kty: 'EC', crv: 'P-256', d: b64u(unhex(p.d)), x: b64u(unhex(p.x)), y: b64u(unhex(p.y)) }, ecdh, false, ['deriveBits']);
const ecPublic = (p) => subtle.importKey('raw', point(p), ecdh, true, []);
const girxInitiator = await agree(ecdh, await ecPrivate(initiator), await ecPublic(responder));
const girxResponder = await agree(ecdh, await ecPrivate(responder), await ecPublic(initiator));
console.log('\nECDH on P-256, RFC 5903 section 8.1');
console.log(` initiator's public key ${point(initiator).length} bytes (0x04, x, y)`);
console.log(` initiator computes ${short(girxInitiator)} (${girxInitiator.length} bytes)`);
console.log(` responder computes ${short(girxResponder)}`);
console.log(` both equal the RFC's girx: ${hex(girxInitiator) === rfcGirx && hex(girxResponder) === rfcGirx}`);
try {
await subtle.importKey('raw', Uint8Array.from([4, ...unhex(responder.x), ...unhex(initiator.y)]), ecdh, true, []);
console.log(' a point not on the curve: imported');
} catch (err) {
console.log(` x of one key with y of the other, which is not a point of P-256: import throws ${err.name}`);
}
// ---- Fresh key pairs ------------------------------------------------------------------------------------------------
console.log('\nTwo generated key pairs of each kind');
for (const [alg, label] of [[x25519, 'X25519'], [ecdh, 'ECDH P-256']]) {
const a = await subtle.generateKey(alg, true, ['deriveBits']);
const b = await subtle.generateKey(alg, true, ['deriveBits']);
const ab = await agree(alg, a.privateKey, b.publicKey);
const ba = await agree(alg, b.privateKey, a.publicKey);
const again = await agree(alg, a.privateKey, b.publicKey);
const publicBytes = new Uint8Array(await subtle.exportKey('raw', a.publicKey)).length;
console.log(` ${label}: public key ${publicBytes} bytes, secret ${ab.length} bytes, equal on both sides: ${hex(ab) === hex(ba)}, same again: ${hex(ab) === hex(again)}`);
} Output
X25519, RFC 7748 section 6.1 Alice's public key 8520f009…aa9b4e6a (32 bytes) Bob's public key de9edb7d…6f882b4f (32 bytes) Alice computes 4a5d9d5b…1e161742 (32 bytes) Bob computes 4a5d9d5b…1e161742 both equal the RFC's K: true ECDH on P-256, RFC 5903 section 8.1 initiator's public key 65 bytes (0x04, x, y) initiator computes d6840f6b…4b9442de (32 bytes) responder computes d6840f6b…4b9442de both equal the RFC's girx: true x of one key with y of the other, which is not a point of P-256: import throws DataError Two generated key pairs of each kind X25519: public key 32 bytes, secret 32 bytes, equal on both sides: true, same again: true ECDH P-256: public key 65 bytes, secret 32 bytes, equal on both sides: true, same again: true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node agree_vectors.mjs
All four derivations equal the RFC values, 4a5d9d5b…1e161742 for X25519 and d6840f6b…4b9442de for P-256. The program also shows two things the RFCs do not. A P-256 public key built from the x of one key and the y of another is refused at import with DataError, because it is not a point of the curve. And generated keys gave the same secret on both sides and again on a repeat: once the keys are chosen, agreement uses no randomness. The two kinds compare like this:
| Property | X25519 | ECDH on P-256 |
|---|---|---|
| Public key, as measured | 32 bytes | 65 bytes: 0x04, x and y |
| Shared secret | 32 bytes | 32 bytes: the x-coordinate |
| A bad peer value | any 32 bytes import as a key | a point off the curve is refused |
| Browsers with it in Web Crypto | Chrome 133, Firefox 130, Safari 17 onward | wherever Web Crypto exists |
Key agreement with X25519 is the usual choice for a new system: its keys are short and any 32 bytes are a valid input. ECDH on P-256 is the answer when a policy allows only NIST curves or older browsers must be served.
Feature-test X25519 in a web app
Try X25519 first and fall back to ECDH on P-256 when the call throws, so that the app works in every browser.
Checkpoint
Check: X25519 and ECDH side by side
The shared secret is only the start
The 32 bytes are not yet something to encrypt with. Four things stand between them and a safe channel, and each has an accepted answer:
- A key for each direction and purpose: HKDF: The HMAC-based key derivation function of RFC 5869. HKDF-Extract turns input keying material and a salt into a pseudorandom key; HKDF-Expand turns that key and an info label into as many bytes as asked for, up to 255 hash blocks. More in “Deriving keys with HKDF” turns the shared secret into separate keys, each bound to both public keys.
- A way to know who the other side is: agreement alone says nothing about identity, and a signature over the exchange, made with a key the peer already trusts, supplies it.
- A key that is used once: ephemeral key: A key pair made for one key agreement and deleted after it. The opposite is a long-term (static) key, which stays in use across many agreements., deleted after the exchange, give forward secrecy: The property that recorded sessions stay closed after a long-term key leaks, because each session's keys came from ephemeral keys that were deleted., so a later leak of a long-term key opens no recorded session.
- A refusal of bad peer values: a value of small order makes the shared secret all zero, and careful code rejects it.
A device that uses the raw secret as an AES key and never asks whose public key it received has a channel that works perfectly and protects nothing against the wrong peer.
Checkpoint
Check: What the raw secret still needs
Session keys from the shared secret, bound to both public keys Pro: opens with a Pro, Premium or Ultimate pass
The 32 bytes from X25519 are the input to a key derivation, not a key. This part derives one key for each direction with HKDF, puts both public keys into the derivation, and audits the result for repeated nonces.
In this part:
- One key for both directions repeats nonces, and the public keys belong in the derivation
- HKDF with a label and both public keys, checked by a nonce audit
- Practise: the same derivation in Python
- Session keys bound to both public keys
- Results of the sample tests
1 diagram · 1 runnable example (JavaScript) · 1 exercise with sample tests · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Who did you agree with? Authenticating the exchange Pro: opens with a Pro, Premium or Ultimate pass
Key agreement tells two sides that they share a secret, not who the other side is. This part puts a relay between two sides in a test of your own code, shows that X25519 alone does not notice it, and fixes that with signatures over the whole exchange.
In this part:
- Agreement with a relay in the path looks like agreement with the peer
- Sign the transcript with a key the other side already trusts
1 diagram · 1 runnable example (JavaScript) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Ephemeral keys, forward secrecy and refusing a zero secret Pro: opens with a Pro, Premium or Ultimate pass
A key that is used once and then deleted cannot be exposed later. This part measures what a leaked server key opens under two designs, then shows the all-zero secret that a peer value of small order produces and the check that refuses it.
In this part:
- A key used once cannot open yesterday’s sessions
- A peer value of small order gives an all-zero secret
2 runnable examples (JavaScript, Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
Check the agreement, the two curves and what the raw secret still needs, weakest idea first:
Practice that adapts to you
Practise this lesson
9 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
What does a key agreement such as X25519 give two parties, and what does it leave out?
Key agreement gives two parties the same secret, computed on each side from its own private key and the other side's public key, so that only public keys cross the network and a watcher cannot compute the secret. X25519 makes 32 bytes this way and ECDH on P-256 another 32. It leaves out three things. The secret is not yet a key, so a key derivation function such as HKDF must turn it into separate keys for each direction. It does not say who the other side is, so the exchange needs a signature, a certificate or a pinned key. And it does not by itself give forward secrecy, which needs a fresh key pair for each exchange, deleted afterwards.
-
Mid to senior
A mobile app and its API run X25519 at login and use the 32-byte result as the AES-GCM key for all traffic in both directions, with message counters from 0 on each side. What is wrong, and how do you fix it without changing the wire protocol more than needed?
-
Senior to staff
A design sends sensor data from 50,000 field devices to a cloud broker. Devices and broker run X25519 at start-up with no signatures, "because the SIM network is private". Is that acceptable, and what do you require before it ships?
-
Mid to senior
A server keeps one X25519 key for years, and clients send a new key for each session. A colleague says recorded sessions would open if the server key leaks. Is the colleague right, and what do you change?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do two parties agree on a secret over a public channel with X25519?
Each picks a private number, a or b, and publishes a·G or b·G. Alice multiplies Bob's public key by a, Bob multiplies Alice's by b, and both reach (ab)·G. A watcher who holds both public keys must solve the Diffie-Hellman problem on the curve, about 2^126 steps on Curve25519. Only two public values cross the network; no secret is ever sent.
-
How do X25519 and ECDH on P-256 compare?
X25519: a 32-byte public key, any 32 bytes import as a key, a 32-byte secret; in Web Crypto from Chrome 133, Firefox 130 and Safari 17. ECDH on P-256: public keys of 65 bytes (0x04, then x and y), a point off the curve is refused with DataError, the 32-byte secret is the x-coordinate; wherever Web Crypto exists. X25519 is the usual choice; P-256 when a policy allows only NIST curves or older browsers must be served.
-
Which published vectors check X25519 and ECDH, and what did the run show?
RFC 7748 section 6.1 for X25519 (shared secret 4a5d9d5b…1e161742) and RFC 5903 section 8.1 for ECDH on P-256 (d6840f6b…4b9442de), computed from each side. Once the keys are chosen, agreement uses no randomness: generated keys gave the same secret on both sides and again on a repeat.
-
Why is the raw shared secret not yet a key to encrypt with?
Four things are missing. A key for each direction and purpose: HKDF turns the secret into separate keys bound to both public keys. A way to know who the other side is: agreement alone says nothing about identity, so a signature over the exchange made with a key the peer already trusts supplies it. A key used once. And a refusal of bad peer values.
-
What do ephemeral keys give, and what does a small-order peer value do?
Ephemeral keys, deleted after the exchange, give forward secrecy: a later leak of a long-term key opens no recorded session. A peer value of small order makes the shared secret all zero, and careful code rejects it.
-
How does a web app use X25519 in every browser?
Feature-test it: try X25519 first and fall back to ECDH on P-256 when the call throws, so that the app works in every browser.
Key takeaways
- Each side combines its own private key with the other’s public key and gets the same 32 bytes; only public keys cross the network.
- X25519 and ECDH on P-256 both reproduced their RFC vectors in Web Crypto, with 32-byte and 65-byte public keys.
- The raw secret is not a key, does not say who the other side is, and needs a key for each direction, an authenticated exchange and a key used once.
References
- RFC 7748: Elliptic Curves for Security (Internet Research Task Force (IRTF))
- RFC 5903: Elliptic Curve Groups modulo a Prime (ECP Groups) for IKE and IKEv2 (Internet Engineering Task Force (IETF))
- RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF) (Internet Engineering Task Force (IETF))
- SP 800-56A Rev. 3: Recommendation for Pair-Wise Key-Establishment Schemes Using Discrete Logarithm Cryptography (National Institute of Standards and Technology (NIST))
- RFC 9846: The Transport Layer Security (TLS) Protocol Version 1.3 (key shares in section 4.3.8, handshake security in appendix F.1) (Internet Engineering Task Force (IETF))
- Node.js 24 documentation: Web Crypto API (X25519, ECDH, HKDF) (OpenJS Foundation (Node.js))
- SubtleCrypto: deriveBits() method, browser compatibility (Mozilla (MDN Web Docs))
- Web Cryptography Level 2 (the import steps of ECDH keys) (World Wide Web Consortium (W3C))
- SafeCurves: ECDLP security, the rho method (Curve25519 at 2^125.8) (Daniel J. Bernstein and Tanja Lange)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress