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 wrappingKey agreement
Who makes the keyone end, at randomboth ends compute it
What crosses the wirethe key, encryptedtwo public key shares
What the receiver needs firsta published long-term key pairnothing: a fresh pair per connection
A later theft of the long-term private keyopens every recorded sessionopens none (forward secrecy)
Used byTLS 1.2's RSA key exchange, PGP and S/MIMETLS 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 -noout prints both halves, priv: and pub:.
  • Alice sends alice-dh.pub to Bob and Bob sends bob-dh.pub to 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 derive takes 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 key
    

    The 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 prints Signature Verified Successfully.
  • This rests on Alice's copy of bob.pub being 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.