Public-key cryptography

Agree on shared keys

Use an established authenticated key-agreement protocol to derive session keys without sending the final shared key across the network.

Key agreement lets two parties derive a shared secret over a public channel without ever sending the secret itself. Diffie-Hellman is the classic construction. X25519 is the modern elliptic-curve version you will meet in practice.

Each side generates a key pair. They exchange public keys. Then each side combines its own private key with the other’s public key. Both arrive at the same secret. An observer who saw both public keys cannot compute it.

Let’s do it in Node:

import { generateKeyPairSync, diffieHellman } from 'node:crypto'

const alice = generateKeyPairSync('x25519')
const bob = generateKeyPairSync('x25519')

const aliceSecret = diffieHellman({ privateKey: alice.privateKey, publicKey: bob.publicKey })
const bobSecret = diffieHellman({ privateKey: bob.privateKey, publicKey: alice.publicKey })

aliceSecret.equals(bobSecret) // true

Nothing secret crossed the wire. Only the two public keys did.

The part the math does not cover

Key agreement does not prove who the other party is. You derived a secret with someone. The math never says with whom.

Picture a client and a server running raw Diffie-Hellman over an untrusted network. An active attacker sits in the middle. They agree on one key with the client and a different key with the server, then read and change traffic between the two. Both victims see a working encrypted channel. This is the textbook man-in-the-middle attack, and unauthenticated key agreement walks straight into it.

Real protocols combine key agreement with an identity check: signatures, certificates, or a pre-established secret. In TLS 1.3, the server signs the handshake transcript with the private key its certificate vouches for. The client now knows the agreement happened with the certificate’s owner, not with whoever was in the middle.

TLS also uses fresh agreement keys for every connection. That is what “ephemeral” means, and it gives you forward secrecy: if the server’s long-term key leaks next year, recorded traffic from today stays unreadable.

Derive, never use raw

The raw shared secret is not your encryption key. Feed it through a key derivation function and produce separate keys, one per purpose:

import { hkdfSync } from 'node:crypto'

const clientKey = Buffer.from(hkdfSync('sha256', aliceSecret, salt, 'client write key', 32))
const serverKey = Buffer.from(hkdfSync('sha256', aliceSecret, salt, 'server write key', 32))

HKDF stretches one secret into independent keys. Each one is bound to a purpose label, so the client’s key and the server’s key can never collide.

My advice is not to assemble a handshake from these pieces yourself. A handshake needs authenticated identities and a defined key schedule. Get either subtly wrong and nothing looks broken until someone exploits it. Use a protocol such as current TLS, or a library like libsodium that wraps agreement, authentication, and derivation into one API.

Try this on your own: trace a TLS connection and note where the ephemeral agreement, the certificate identity, the transcript signature, and the derived traffic keys appear. Connect with the expected hostname and confirm it verifies. Then connect to a test endpoint with the wrong identity and watch the authentication fail.

Lesson completed