Crypto on the Command Line, Part 7: Key Agreement
In a key agreement each end sends a public key share, combines its own private half with the other's public
half, and both arrive at the same secret. No key crosses the wire. This post runs the last three scenes of the
Security Primitives
tool with openssl:
- how key agreement differs from sending the key wrapped;
- Alice and Bob making X25519 key pairs, and computing one shared secret;
- deriving an AES key from it with HKDF;
- Mallory in the middle, agreeing a secret with each end;
- Bob signing his share, which stops her.
Two ways to a shared key
Key agreement is the alternative to sending the shared key encrypted. AES and HMAC both need one key at both ends, and there are two ways to get it there over an open wire:
- Key wrapping ( Part 4 ): Alice makes a random key, encrypts it with Bob's public key and sends it; Bob decrypts it with his private key.
- Key agreement (this post): each end sends a public key share, and each computes the same key from its own private half and the other's share. The key itself never crosses the wire, encrypted or not.
| Key wrapping | Key agreement | |
|---|---|---|
| Who makes the key | one end, at random | both ends compute it |
| What crosses the wire | the key, encrypted | two public key shares |
| What the receiver needs first | a published long-term key pair | nothing: a fresh pair per connection |
| A later theft of the long-term private key | opens every recorded session | opens none (forward secrecy) |
| Used by | TLS 1.2's RSA key exchange, PGP and S/MIME | TLS 1.3, SSH, WireGuard, Signal |
- Forward secrecy is the reason TLS 1.3 dropped key wrapping: a recorded session stays closed even after a server's key is stolen. Forward secrecy , at the end of this post, shows it.
- Either way, what comes out is an ordinary shared key, used for AES and HMAC as in Parts 2 and 6.
The tools on Ubuntu 26.04
The commands are shown as a terminal session:
$starts a command: type what follows it;>marks a line that continues the command above it, for a command too long for one line;- a line with neither is output. A command with no lines under it prints nothing.
openssl (3.5 on 26.04) and xxd, and for the last section Bob's RSA key pair from
Part 3
:
1$ sudo apt update
2$ sudo apt install openssl xxd
3$ mkdir -p ~/crypto-lab && cd ~/crypto-lab
4$ openssl genpkey -quiet -algorithm RSA -out bob.key # skip if Part 3 made it
5$ openssl pkey -in bob.key -pubout -out bob.pub
The key shares
Each end makes a fresh X25519 key pair, for this one connection. The public half is the key share:
1$ openssl genpkey -algorithm X25519 -out alice-dh.key
2$ openssl pkey -in alice-dh.key -pubout -out alice-dh.pub
3$ openssl genpkey -algorithm X25519 -out bob-dh.key
4$ openssl pkey -in bob-dh.key -pubout -out bob-dh.pub
5$ cat bob-dh.pub
6-----BEGIN PUBLIC KEY-----
7MCowBQYDK2VuAyEABX+hcyfsxxy5/cQz/s3kIJxO0f3uEJBkFpYA5Z71eFM=
8-----END PUBLIC KEY-----
- An X25519 key is 32 bytes, private and public alike; the file adds 12 bytes naming the algorithm.
openssl pkey -in alice-dh.key -text -nooutprints both halves,priv:andpub:.- Alice sends
alice-dh.pubto Bob and Bob sendsbob-dh.pubto Alice. Eve copies both.
The shared secret
pkeyutl -derive combines a private key with the other end's public key. Each side runs it with its own pair
of files:
1$ openssl pkeyutl -derive -inkey alice-dh.key -peerkey bob-dh.pub -out alice.shared
2$ openssl pkeyutl -derive -inkey bob-dh.key -peerkey alice-dh.pub -out bob.shared
3$ xxd -p -c 32 alice.shared
44a574f3c7118abb7b79db1284d1401bc58c0706323a33cfba484959ab2470879
5$ xxd -p -c 32 bob.shared
64a574f3c7118abb7b79db1284d1401bc58c0706323a33cfba484959ab2470879
Both get the same 32 bytes. A public key is the private key times a fixed point on the curve, so Alice's private key times Bob's public key and Bob's private key times Alice's are the same product.
Eve holds both public halves, and
derivetakes a private key:1$ openssl pkeyutl -derive -inkey alice-dh.pub -peerkey bob-dh.pub 2Could not find private key from alice-dh.pub 3...:error:1608010C:STORE routines:ossl_store_handle_load_result:unsupported:... 4pkeyutl: Error loading keyThe arithmetic would not help her either: getting a private key back from a public one is the problem the curve is chosen for.
An AES key from the secret
The shared secret is not used as a key directly. A key derivation function, HKDF, hashes it into keys of the
size needed, with a label (info) that gives each purpose a key of its own:
1$ openssl kdf -keylen 32 -kdfopt digest:SHA256 \
2> -kdfopt hexkey:$(xxd -p -c 32 alice.shared) -kdfopt info:'pay channel' \
3> -binary -out brown.key HKDF
4$ xxd -p -c 32 brown.key
58a5c09842dec1e955c0cd080e22322ce86038b4f3089d627564f1256e4d42765
Bob runs the same with bob.shared and gets the same key. From here AES carries the data, as in
Part 2
:
1$ openssl rand -hex 16 > msg.iv
2$ printf 'pay carol 10' | openssl enc -aes-256-cbc -K $(xxd -p -c 32 brown.key) -iv $(cat msg.iv) > msg.enc
TLS 1.3 does this for every connection: an X25519 agreement, then HKDF, with a label for each direction.
Mallory in the middle
A key share is a number: nothing on it says whose it is. Mallory catches each share and sends her own in its place:
1$ openssl genpkey -algorithm X25519 -out mallory-dh.key
2$ openssl pkey -in mallory-dh.key -pubout -out mallory-dh.pub
Alice, thinking mallory-dh.pub is Bob's, and Mallory, with Alice's real share:
1$ openssl pkeyutl -derive -inkey alice-dh.key -peerkey mallory-dh.pub | xxd -p -c 32
224fcdbfa141c4ea5a84396d46500f9106961f8d3f7b0573c859ed88480fdf56e
3$ openssl pkeyutl -derive -inkey mallory-dh.key -peerkey alice-dh.pub | xxd -p -c 32
424fcdbfa141c4ea5a84396d46500f9106961f8d3f7b0573c859ed88480fdf56e
Bob, thinking mallory-dh.pub is Alice's, and Mallory, with Bob's real share:
1$ openssl pkeyutl -derive -inkey bob-dh.key -peerkey mallory-dh.pub | xxd -p -c 32
25421e184dd2623c5afc5bd403a03ce164dc4140a611e552e4aa6108fff3d4f25
3$ openssl pkeyutl -derive -inkey mallory-dh.key -peerkey bob-dh.pub | xxd -p -c 32
45421e184dd2623c5afc5bd403a03ce164dc4140a611e552e4aa6108fff3d4f25
- Mallory holds both secrets. She decrypts what Alice sends, reads it, encrypts it again for Bob, and both connections work.
- The encryption is perfect; it is with the wrong party.
A signed key share
Bob signs his share with his long-term key, by hand as in Part 5 : the digest first, then the private key:
1$ openssl dgst -sha256 -binary bob-dh.pub > share.sha256
2$ openssl pkeyutl -sign -inkey bob.key -pkeyopt digest:sha256 -in share.sha256 -out share.sig
He sends bob-dh.pub and share.sig. Mallory swaps the share for hers and keeps the signature, since she
cannot make one with bob.key. Alice hashes the share she received and checks the signature with bob.pub:
1$ openssl dgst -sha256 -binary mallory-dh.pub > got.sha256
2$ openssl pkeyutl -verify -pubin -inkey bob.pub -pkeyopt digest:sha256 \
3> -in got.sha256 -sigfile share.sig
4...:error:02000068:rsa routines:ossl_rsa_verify:bad signature:...
5...:error:1C880004:Provider routines:rsa_verify_directly:RSA lib:...
6...:error:030000EA:digital envelope routines:EVP_PKEY_verify:provider signature failure:...
7Signature Verification Failure
- The signature covers Bob's share, not the one that arrived, so Alice drops the connection.
- With Bob's real share in
got.sha256, the same command printsSignature Verified Successfully. - This rests on Alice's copy of
bob.pubbeing Bob's. A certificate is a public key and a name, signed by someone Alice already trusts; TLS Concepts picks up the story there.
Forward secrecy
When the connection ends, both sides delete their private halves:
1$ rm alice-dh.key bob-dh.key mallory-dh.key alice.shared bob.shared
- Eve's recording of the connection holds two public halves and ciphertext, and nothing left anywhere opens it.
- Even a later theft of
bob.key, Bob's long-term key, opens no recorded session: it never touched the session's key. With key wrapping ( Part 4 ) it would open every one.