doomsparkles/nnshake
Simple ECDH handshake protocol in Rust, based on X25519 and ChaCha20-Poly1305
nnshake
A Rust implementation of a simple Elliptic Curve Diffie-Hellman
(ECDH) channel-binding handshake protocol, based on X25519 and
ChaCha20-Poly1305, for establishing forward-secure encrypted and
authenticated sessions between endpoints such as clients and servers.
This is experimental software. It has not received formal security
review and should not be entrusted with sensitive data. Use at your own
risk.
Overview
This crate implements functions for performing a two-phase handshake
between two endpoints. The phases of the handshake are:
- kx (ephemeral ECDH key exchange/agreement)
- auth (Peer authentication)
The kx phase establishes an encrypted session in the form of a
symmetric keypair shared by the two endpoints. This keypair can be used
for bidirectional encrypted data transfer. However, at the end of this
phase, the session is entirely unauthenticated—the endpoints may be
talking to an MITM attacker rather than to each other.
The auth phase allows each endpoint to confirm the identity of the
other using an authentication method. This phase is tied to the kx
phase using channel binding. In addition to a shared symmetric
keypair, the kx phase produces a shared secret channel binding
token. This token is used in the auth phase to link the
authentication with the encrypted session. This two-phase approach
separates the concerns of establishing encrypted sessions and
authentication, allowing for a range of different authentication
mechanisms.
This library does not perform any I/O; it just accepts byte slices from
the user for input and output. As such, it can easily be embedded in
higher-level protocols and network applications.
The following cryptographic primitives are used in the current version:
- X25519 (RFC 7748) for ECDH key agreement
- ChaCha20-Poly1305 (RFC 7539) for AEAD symmetric encryption
- HKDF (RFC 5869) with HMAC-SHA-512 (using fixed-length salt) for key
derivation - BLAKE2b for hashing of user-supplied additional data
There is, by design, no provision made for negotiating or using
different primitives. The ring library is used for all primitives
except BLAKE2b.
Protocol
Call the endpoints Alice (A) and Bob (B). Assume that Alice is the
initiator of the session.
kx phase
-
Alice generates an ephemeral ECDH key a for this session with
corresponding public key a.pub. -
Alice sends a.pub to Bob.
-
When Bob receives a.pub, he generates an ephemeral key b.
-
Bob sends b.pub to Alice.
That is:
A: a.pub → B
B: b.pub → A
-
Both parties compute k = ECDH(a, b.pub) = ECDH(b, a.pub).
-
Both parties use k to derive:
- a shared pair of symmetric keys (up, dn). The upstream
key up is used for encryption by the session initiator Alice
(hence for decryption by Bob), and vice-versa for the downstream
key dn. - A shared channel-binding token t.
- a shared pair of symmetric keys (up, dn). The upstream
auth phase
This phase allows one or both endpoints to authenticate the other via
some authentication mechanism that makes use of the channel-binding
token t derived in the kx phase. Many different such mechanisms
are possible, and they can be flexibly combined in different ways.
Example 1: Authentication Using a Static Public Key
Suppose Alice is a client and Bob is a server with a well-known static
ECDH public key bs.pub. Alice can verify she is really talking to
Bob as follows:
Let E_k(m) denote symmetric encryption of message m with key k.
-
Alice generates a random challenge code r and an ephemeral ECDH
challenge key c / c.pub. This key will be used only to
encrypt a single message to Bob. -
Alice computes cs = ECDH(c, bs.pub).
-
Alice sends E_up(c.pub, E_cs(r ^ t)) to
Bob. That is, she takes the XOR of the challenge code and the channel
binding token, encrypts it under the key cs, prepends c.pub,
and sends to Bob (encrypted under the upstream session key up). -
Bob receives and unwraps this message, computes cs = ECDH(bs,
c.pub), unwraps the inner message r ^ t with cs,
and XORs it with t, yielding r. -
Bob sends E_dn(r) to Alice.
-
Alice receives and unwraps this message, and verifies that the
received value of r matches the one she generated in Step 1.
That is:
A: E_up(c.pub, E_cs(r ^ t)) → B
B: E_dn(r) → A
This protocol works because if Mallory is an MITM between Alice and Bob,
then she can't unwrap the inner message (because it's encrypted using
Bob's static public key bs.pub); and if she forwards it to Bob, then
the token Bob uses to perform the XOR in Step 4 will be the binding
token for his channel with Mallory, instead of the token he shares with
Alice. The response he sends in Step 5 will thus fail Alice's
verification in Step 6.
(This protocol was proposed by @eternaleye and inspired by
TCPcrypt.)
Example 2: Authentication Using a Pre-Shared Key
Suppose Alice and Bob share an authentication key K. Then one party
can authenticate to the other by sending E(H(K, t)), where E
is the session encryption and H(key, message) is a suitable
cryptographic keyed hash or HMAC function.
Example 3: Hash Puzzles
Suppose Alice is requesting a service from Bob. Bob may wish to
rate-limit such requests and ensure "good faith" on the part of clients
to mitigate denial-of-service attacks. Bob can "authenticate" Alice by
sending a hash puzzle to Alice that requires knowledge of the binding
token t to solve, ensuring any solution he receives is really
provided by Alice.
Example Usage
Currently this crate only implements static public key
authentication. Here's a basic example that shows the complete handshake
for both client and server:
extern crate nnshake;
// A static keypair can be generated with examples/static-keygen
const SERVER_STATIC_PRIVKEY: [u8; 32] = [
228, 172, 49, 122, 10, 86, 15, 63,
30, 82, 44, 209, 15, 142, 38, 144,
20, 110, 164, 245, 17, 109, 59, 27,
158, 22, 80, 64, 178, 92, 10, 138,
];
const SERVER_STATIC_PUBKEY: [u8; 32] = [
85, 215, 107, 196, 200, 26, 154, 112,
186, 205, 170, 96, 156, 87, 242, 31,
134, 36, 10, 205, 133, 18, 230, 211,
38, 179, 240, 57, 75, 251, 43, 122,
];
fn main() {
use nnshake::{Random, Client, Server};
let rng = Random::new();
// Message 1: Client generates key and sends pubkey to server
let mut c = Client::new(&rng).expect("Client keygen failed");
// Message 2: Server receives client pubkey, generates key and
// sends pubkey to client
let mut s = Server::new(&rng).expect("Server keygen failed");
// Both parties compute ECDH key exchange to derive a shared session key
s.kx(c.public_key()).expect("S: Key exchange failed");
c.kx(s.public_key()).expect("C: Key exchange failed");
// Message 3: Client generates a challenge message to authenticate
// server based on its static public key, and sends to server
let mut challenge_frame = [0u8; 96];
c.challenge(&SERVER_STATIC_PUBKEY, &mut challenge_frame)
.expect("C: Challenge generation failed");
// Message 4: Server receives challenge, generates response, and
// sends to client
let mut response_frame = [0u8; 48];
s.solve_challenge(&SERVER_STATIC_PRIVKEY, &mut challenge_frame, &mut response_frame)
.expect("S: Challenge solution failed");
// Client receives and validates server's response message
c.check_response(&mut response_frame).expect("C: Invalid response");
// Client and server now share a pair of keys (upstream, downstream)
// that can be used for symmetric AEAD data transfer
let (c_up_key, c_dn_key) = c.finish().expect("C: Finish failed");
let (s_up_key, s_dn_key) = s.finish().expect("S: Finish failed");
assert_eq!((*c_up_key, *c_dn_key), (*s_up_key, *s_dn_key));
}Notes
-
The API is simple and hard to misuse. Each of the main handshake
methods can only be called when the handshake is at the appropriate
step, and upon success, each transitions the handshake to the next
step. -
When the handshake finishes, a pair
(upstream, downstream)of keys
is returned. The intent is thatupstreamis used for client→server
encryption (encryption by the client and decryption by the server),
and thatdownstreamis used conversely. -
The library itself does not use the
upstreamanddownstreamkeys,
but rather derives its own for use in the challenge and response
steps. This is to ensure that all AEAD nonce values can safely be used
by the user after the handshake finishes. -
Support is provided for supplying additional data during the handshake
via theupdate_ad_tx()andupdate_ad_rx()methods. These methods
should be used at every step to include any transmitted and received
cleartext message headers in the respective hash state. This provides
security against alteration of the cleartext headers of messages in
transit. If such alteration occurs, the tx hash of the sender will
fail to match the rx hash of the receiver. Since this hash is passed
as additional data during symmetric AEAD encryption and decryption,
non-matching hashes will cause a handshake failure when attempting to
decrypt Message 3 or Message 4. See the
simple-ad example. -
Some care is taken to ensure that stack memory containing sensitive
key material is cleared after use. Theupstreamanddownstream
keys returned to the user when the handshake finishes are wrapped in a
struct that clears its contents when dropped. This struct implements
Deref, so the array of key bytes can be accessed with the*
operator. -
A static-keygen tool is provided in the
examples that can be used to generate static ECDH keypairs. This tool
will print the private and public keys in Base64 and hex format.
Building
Currently (Oct 2017) the ring crate does not support static ECDH
keys. Therefore,
building this crate requires applying a small patch to a
local copy of ring. This patch just makes a few ring datatypes
public instead of private:
$ git clone https://github.com/doomsparkles/nnshake
$ cd nnshake
$ git clone https://github.com/briansmith/ring
$ (cd ring ; git apply ../ring.diff)
$ cargo build --examplesLicense
Distributed under the MIT License.