I recently created an example project which implements an ephemeral key exchange between a client and server over TCP. The program uses the Ring cryptography library to compute a shared secret using the X25519 algorithm. Two session keys are derived from the output of the key exchange using HKDF and then messages are encrypted and authenticated between the client and server using AES-GCM with a hash transcript to verify that both the client and the server are seeing exactly the same messages in the same order. In this post I walk through how I built the project and explain the reasoning and design considerations at each step.
Note that the key exchange used in this project is unauthenticated on both sides because the keys are ephemeral and so there is no way for the client to know if it is communicating with the intended server, and vice versa. The means that a man in the middle attacker could intercept the encrypted communications by doing an ephemeral key exchange with the client and server at the same time and forwarding messages between them. In order to authenticate the server to the client we would need to have some root of trust that we can use to trust the server such as using certificates or static public keys which are verified as trusted out of band.
Create a TCP Server
I started the project by creating the server which listens on a TCP socket and waits for connections from clients. When a new client connection comes in the server accepts the connection and then proceeds to send and receive messages over the TcpStream
. In the example below we simply start the server and then once a connection is created we wait for the client to send a message, print it and then reply to the client with a response message. Note that this example TCP server is single threaded and so would only be able to accept connections from one client at a time.
const HOST: &str = "127.0.0.1";
const PORT: &str = "7654";
const BUFFER_SIZE: usize = 50;
fn main() {
// Create a new socket
let listener = TcpListener::bind(format!("{}:{}", HOST, PORT)).unwrap();
loop {
// Create a new connection with the client
let (mut stream, address) = listener.accept().unwrap();
println!("Connection established with client at {:?}", address);
loop {
// Read and write using the connection
handle_connection(&mut stream);
}
}
}
fn handle_connection(stream: &mut TcpStream) {
let mut buffer = [0; BUFFER_SIZE];
// read from stream
stream.read(&mut buffer).unwrap();
println!("Server received request: {:?}", String::from_utf8(buffer.to_vec()).unwrap());
// write to stream
let response = "Response data\n";
stream.write(response.as_bytes()).unwrap();
}
Create a TCP Client
Next we create the TCP client which connects to the server and once connected, sends a request to the server and then waits for the response which it then prints to the console. So far nothing is encrypted and the client and server are both assuming the data can be parsed as UTF-8 strings. The client sends the data over the TcpStream
once every 5 seconds.
const HOST: &str = "127.0.0.1";
const PORT: &str = "7654";
const BUFFER_SIZE: usize = 50;
fn main() {
let mut stream = TcpStream::connect(format!("{}:{}", HOST, PORT)).unwrap();
let mut buffer = [0; BUFFER_SIZE];
loop {
// write to stream
let request = "Request data\n";
stream.write(request.as_bytes()).unwrap();
// read from stream
stream.read(&mut buffer).unwrap();
println!("Client received response: {:?}", String::from_utf8(buffer.to_vec()).unwrap());
sleep(Duration::new(5, 0));
}
}
Implementing the ECDH Key Exchange
For this part I created a helper type called EcdhEphemeralKeyExchange
which is used by both the client and server programs and which internally uses the Ring cryptography library to implement the key exchange. An enum type Actor
is used to determine if the instance is being called from the client or server. The type creates a new SystemRandom
instance which is used as a source of entropy to generate the ephemeral keys used in the key exchange. During the key exchange the public keys are stored in the pub_key
and peer_pub_key
variables which are used later as input into the hash transcript. The run method executes the key change over the TcpStream
which is passed in as a parameter by sending and receiving the public keys to and from the peer.
#[derive(Debug)]
enum Actor {
CLIENT, SERVER
}
pub struct EcdhEphemeralKeyExchange {
actor: Actor,
rand: SystemRandom,
pub_key: Option<Vec<u8>>,
peer_pub_key: Option<Vec<u8>>,
}
impl EcdhEphemeralKeyExchange {
pub fn new_client() -> Self {
Self::new(CLIENT)
}
pub fn new_server() -> Self {
Self::new(SERVER)
}
fn new(actor: Actor) -> Self {
EcdhEphemeralKeyExchange {
actor,
rand: SystemRandom::new(),
pub_key: None,
peer_pub_key: None
}
}
pub fn client_pub_key(&self) -> Option<Vec<u8>> {
match self.actor {
CLIENT => return self.pub_key.clone(),
SERVER => return self.peer_pub_key.clone()
}
}
pub fn server_pub_key(&self) -> Option<Vec<u8>> {
match self.actor {
CLIENT => self.peer_pub_key.clone(),
SERVER => self.pub_key.clone()
}
}
pub fn run(&mut self, stream: &mut TcpStream) -> Result<(Vec<u8>, Vec<u8>), Unspecified> {
let alg = &X25519;
let my_private_key: EphemeralPrivateKey = EphemeralPrivateKey::generate(alg, &self.rand)?;
let my_public_key: PublicKey = my_private_key.compute_public_key()?;
println!("{:?}: public_key = {}", self.actor, hex::encode(my_public_key.as_ref()));
// Send our public key to the peer
stream.write_all(my_public_key.as_ref()).unwrap();
// Receive a public key from the peer
let mut peer_public_key = [0u8; 32];
stream.read_exact(&mut peer_public_key).unwrap();
println!("{:?}: peer_public_key = {}", self.actor, hex::encode(peer_public_key.as_ref()));
// The peer public key needs to be parsed before use so wrap it creating as an instance of UnparsedPublicKey
let peer_public_key = UnparsedPublicKey::new(alg, peer_public_key);
// Store public keys for usage in the HashTranscript
self.pub_key = Some(my_public_key.as_ref().to_vec());
self.peer_pub_key = Some(peer_public_key.bytes().to_vec());
// run ECDH to agree on a shared secret
agree_ephemeral(my_private_key,
&peer_public_key,
|shared_secret| self.kdf(shared_secret))
}
Using HKDF to Derive Session Keys
The output of ECDH should not directly be used as a session key to encrypt messages because the bits are not uniformly distributed. For this reason it is best practice to run the ECDH output through a key derivation function such as HKDF which will produce a uniformly distributed output which is cryptographically indistinguishable from random.
In the code below I’ve added a kdf
method to the EcdhEphemeralKeyExchange
type which is called just after the key change. In this method we use the HKDF extract and expand functions implemented by the Ring cryptography library to derive the session keys. The HKDF extract function is used to produce the uniformly distributed output and HKDF expand is used to stretch the key material to double the length so that we can create two session keys, one for the client to server communication and the other for the server to client communication. Having two session keys limits the scope of a compromise in the event that one of the session keys are recovered by an attacker.
fn kdf(&self, shared_secret: &[u8]) -> (Vec<u8>, Vec<u8>) {
// As recommended in RFC 7748 we should apply a KDF on the key material here
let salt = Salt::new(HKDF_SHA256, b""); // salt is optional
let pseudo_rand_key: Prk = salt.extract(shared_secret);
let mut context = self.client_pub_key().unwrap();
context.append(&mut self.server_pub_key().unwrap());
let context_data = [context.as_slice()];
const SESSION_KEY_LEN: usize = 2 * SHA256_OUTPUT_LEN;
struct SessionKeyType;
impl KeyType for SessionKeyType {
fn len(&self) -> usize {
SESSION_KEY_LEN
}
}
let output_key_material = pseudo_rand_key.expand(&context_data, SessionKeyType).unwrap();
let mut result = [0u8; SESSION_KEY_LEN];
output_key_material.fill(&mut result).unwrap();
let session_key = result.split_at(SESSION_KEY_LEN / 2);
(session_key.0.to_vec(), session_key.1.to_vec())
}
Using Authenticated Encryption to Send & Receive Encrypted Messages
Next we create two helper types AeadEncrypter
and AeadDecrypter
which handle the encryption and decryption for each session. Both the client and the server will each create an instance of both types, one for each direction of the communication. For example the client will create an AeadEncrypter
to send messages to the server and an AeadDecrypter
to receive messages from the server. Each type gets constructed with the respective session key. These types both use AES-GCM from the Ring cryptography library which we use for authenticated encryption. AES-GCM requires passing a nonce to each encryption operation in order to randomise the encryption. The nonce must be unique and so we need to take care to never use repeated nonces for the same encryption key. The nonce doesn’t need to be unpredictable so we simply use a counter starting from one which is incremented for each encryption operation.
In the code below we implement the NonceSequence
interface as required by the library which takes the starting index as a parameter and increments the number to be used as the nonce with each call to the encryption and decryption operations.
pub struct CounterNonceSequence(u32);
impl NonceSequence for CounterNonceSequence {
// called once for each seal operation
fn advance(&mut self) -> Result<Nonce, Unspecified> {
let mut nonce_bytes = vec![0; NONCE_LEN];
nonce_bytes[8..].copy_from_slice(&self.0.to_be_bytes());
//println!("nonce_bytes = {}", hex::encode(&nonce_bytes));
self.0 += 1; // advance the counter
Nonce::try_assume_unique_for_key(&nonce_bytes)
}
}
The AeadEncrypter
type contains a SealingKey
which is constructed by passing in the session key and the nonce sequence. This allows us to use the same encryption key for the entire session. If we need to use a new key for another session we create another instance of AeadEncrypter
. The encrypt
method takes the data to be encrypted and the associated data as parameters. The associated data is not encrypted but rather is used to bind and authenticate some context data to the encryption.
pub struct AeadEncrypter {
sealing_key: SealingKey<CounterNonceSequence>
}
impl AeadEncrypter {
pub fn new(key: &[u8]) -> Self {
let unbound_key = UnboundKey::new(&AES_256_GCM, &key).unwrap();
AeadEncrypter {
sealing_key: SealingKey::new(unbound_key, CounterNonceSequence(1))
}
}
pub fn encrypt(&mut self, data: &[u8], associated_data: &[u8]) -> Result<Vec<u8>, Unspecified> {
let associated_data = Aad::from(associated_data);
let mut in_out = data.to_vec();
let tag = self.sealing_key.seal_in_place_separate_tag(associated_data, &mut in_out)?;
Ok([&in_out, tag.as_ref()].concat())
}
}
The AeadDecrypter
type is implemented similarly to the AeadEncrypter
and contains an OpeningKey
type which is constructed using the session key and nonce sequence as input. The decrypt
method takes the ciphertext and associated data as parameters. If the associated_data passed in does not match the data used in the encryption operation then the decryption will fail. This prevents attackers from tampering with the ciphertext while it is in transit.
pub struct AeadDecrypter {
opening_key: OpeningKey<CounterNonceSequence>
}
impl AeadDecrypter {
pub fn new(key: &[u8]) -> Self {
let unbound_key = UnboundKey::new(&AES_256_GCM, &key).unwrap();
AeadDecrypter {
opening_key: OpeningKey::new(unbound_key, CounterNonceSequence(1))
}
}
pub fn decrypt(&mut self, ciphertext: &[u8], associated_data: &[u8]) -> Result<Vec<u8>, Unspecified> {
let associated_data = Aad::from(associated_data);
let mut in_out = ciphertext.to_vec();
Ok(self.opening_key.open_in_place(associated_data, &mut in_out)?.to_vec())
}
}
Hash Transcript
A hash transcript is used to authenticate each AEAD decryption operation (similar to the design used in Noise Protocol Framework). We append the public keys and every message sent and received to the hash transcript in order to enforce that both the client and server are seeing the exact same messages in the same order. The hash transcript is implemented by using SHA-256 to hash new messages with the previous transcript hash which creates a kind of hash chain.
Here is the HashTranscript
type which has an append
method which hashes a new value with the previous hash to produce the updated transcript hash. This type is used by both the client and the server to record the full transcript of messages sent and received and the public keys used in the key exchange. The as_bytes method returns the transcript hash which is passed in as authenticated data to the AeadEncrypter::encrypt
and AeadDecrypter::decrypt
methods respectively.
pub struct HashTranscript {
hash: Hash
}
impl HashTranscript {
pub fn new() -> Self {
HashTranscript{ hash: Sha256.hash(b"") }
}
pub fn append(&mut self, bytes: &[u8]) {
let mut ctx = Sha256.new_context();
ctx.update(self.hash.as_bytes());
ctx.update(bytes);
self.hash = ctx.finish();
}
pub fn as_bytes(&self) -> &[u8] {
println!("transcript = {}", self.hash.to_hex());
self.hash.as_bytes()
}
}
Putting it All Together
Finally I updated the client and server to use these helper types to run the key exchange, and send and receive the encrypted messages.
Here is the handle_session
function defined in the client. Once we have a connection, we run the key exchange to create the two session keys, then we append the public keys to the HashTranscript
. Next, new AeadEncrypter
and AeadDecrypter
instances are created which are then used to encrypt and decrypt messages every 5 seconds. After each encryption and decryption operation we append each plaintext message to the hash transcript.
fn handle_session(mut stream: TcpStream) -> io::Result<usize> {
// Run the ephemeral to ephemeral key exchange as the client and return the session keys.
let mut ecdh = EcdhEphemeralKeyExchange::new_client();
let (client_to_server, server_to_client) = ecdh.run(&mut stream)
.map_err(|_e| Error::new(ErrorKind::Other, "Key exchange failed"))?;
let mut transcript = HashTranscript::new();
transcript.append(&ecdh.client_pub_key().unwrap());
transcript.append(&ecdh.server_pub_key().unwrap());
let mut encrypter = AeadEncrypter::new(&client_to_server);
let mut decrypter = AeadDecrypter::new(&server_to_client);
let mut counter: u32 = 0;
loop {
counter += 1;
// write to stream
let request = format!("Hello {}", counter);
let ciphertext = encrypter.encrypt(request.as_bytes(), transcript.as_bytes())
.map_err(|_e| Error::new(ErrorKind::Other, "Encryption failed"))?;
transcript.append(request.as_bytes());
let bytes_written = stream.write(&ciphertext)?;
println!("Client sent request {:?} bytes", bytes_written);
// read from stream
let mut buffer = [0; BUFFER_SIZE];
let bytes_read = stream.read(&mut buffer)?;
let plaintext = decrypter.decrypt(&buffer[..bytes_read], transcript.as_bytes())
.map_err(|_e| Error::new(ErrorKind::Other, "Decryption failed"))?;
transcript.append(&plaintext);
println!("Client received response: {:?}", String::from_utf8(plaintext)
.map_err(|e| Error::new(ErrorKind::Other, e.to_string()))?);
sleep(Duration::new(5, 0));
}
To see how all these code samples fit together you can view the full code example on Github here.
Running the Code
To start the server run:
cargo run --bin ecdh-server
To start the client run:
cargo run --bin ecdh-client
Conclusion
In this post I showed how to create a simple project which implements a ECDH key exchange using Rust and the Ring cryptography library. We created helper types which use the ring::agreement
, ring::hkdf
and ring::aead
modules for each of the required cryptography primitives. We covered how to create a tcp client and server, how to implement the ECDH key exchange, how to derive session keys using HKDF and how to encrypt and decrypt using AES-GCM with a hash transcript for message authentication.