Secure a request-reply exchange with CURVE and Noise_XX

For Rust developers who have an rzmq service working and now need it encrypted

You will take a small REQ/REP price lookup over TCP, generate keypairs for it with the rzmq CLI and turn on CURVE encryption. Then you run the same exchange over Noise_XX. Along the way you will point a client at the wrong server key and watch the handshake fail in the socket monitor.

Before you start

You need Rust with Cargo. rzmq comes from crates.io:

rzmq = "0"

Set up the project#

Start a new binary crate.

cargo new secure-quotes
cd secure-quotes

CURVE and Noise_XX are both optional features of rzmq, so you name them in the dependency. The default features (ipc, inproc and plain) stay on. Your [dependencies] section should look like this:

Cargo.toml
[dependencies]
rzmq = { version = "0", features = ["curve", "noise_xx"] }
tokio = { version = "1", features = ["full"] }

rzmq has four security mechanisms and each socket picks one through its options:

  • NULL is the default and does no security at all.
  • PLAIN (default feature) sends a username and password in the clear, which is authentication without encryption.
  • CURVE (curve feature) is the CurveZMQ protocol. It encrypts the session, authenticates the server to the client and interoperates with libzmq's CURVE.
  • Noise_XX (noise_xx feature) uses the Noise Protocol Framework's XX handshake. It also encrypts and authenticates but only talks to other rzmq peers.

This tutorial covers the two that encrypt.

Generate the keys#

Both mechanisms use Curve25519 keypairs: a 32-byte secret key and the 32-byte public key derived from it. The rzmq command line tool generates them with keygen curve for CURVE and keygen noise-xx for Noise_XX. Install it once:

cargo install rzmq_cli

Make a keypair for the server and one for the client, for each mechanism. --file-format binary writes the raw 32 bytes, which is what rzmq's key options take, so your program can read the files as they are.

rzmq keygen curve --name server --output-dir keys --file-format binary
rzmq keygen curve --name client --output-dir keys --file-format binary
rzmq keygen noise-xx --name server --output-dir keys --file-format binary
rzmq keygen noise-xx --name client --output-dir keys --file-format binary

For each keypair the tool prints both keys in hex along with the file names. The first command prints:

Generating CURVE (Curve25519) keypair with name: 'server' in directory: 'keys'
File content format: binary, Stdout display format: hex
Secret key (binary) saved to: "keys/server.curve.sk"
Public key (binary) saved to: "keys/server.curve.pk"

--- Generated Keys (Display Format: hex) ---
Secret Key (Hex): <64 hex characters>
Public Key (Hex): <64 hex characters>

IMPORTANT: Store the secret key file ("keys/server.curve.sk") securely!
The public key file ("keys/server.curve.pk") can be shared with peers.

Remember: rzmq socket options expect RAW BYTES for keys.
If your key files are in hex or base64 format, your application will need to decode them before setting the socket options.

Your keys are random, so the hex is shown as placeholders here. The secret key only needs to exist in the .sk file, so don't paste the hex anywhere.

The .sk files are created readable by your user only and the .pk files are world-readable. Keep the whole directory out of git before you do anything else:

$ echo "/keys" >> .gitignore
$ ls -l keys
-rw-r--r--  1 you  staff  32 Sep 24 09:00 client.curve.pk
-rw-------  1 you  staff  32 Sep 24 09:00 client.curve.sk
-rw-r--r--  1 you  staff  32 Sep 24 09:00 client.noise.pk
-rw-------  1 you  staff  32 Sep 24 09:00 client.noise.sk
-rw-r--r--  1 you  staff  32 Sep 24 09:00 server.curve.pk
-rw-------  1 you  staff  32 Sep 24 09:00 server.curve.sk
-rw-r--r--  1 you  staff  32 Sep 24 09:00 server.noise.pk
-rw-------  1 you  staff  32 Sep 24 09:00 server.noise.sk

In a real deployment each side only holds what it needs. The server machine has its own .sk files. Each client has its own .sk files plus copies of the server's .pk files. Public keys are safe to ship with the client or commit to a repo. Secret keys belong in a file owned by the service's user or in your secret store, loaded from a path in config at startup. Here both ends run in one process, so both sets sit in keys/.

Start with an open exchange#

Before adding security, here is the exchange you are going to protect. A REP server answers one price question and a REQ client asks it. The client opens a monitor before connecting and waits for HandshakeSucceeded before it sends, which is the point where rzmq says the connection is ready for application data. With NULL security that handshake is just the ZMTP greeting.

Replace src/main.rs with this:

src/main.rs
use rzmq::socket::SocketEvent;
use rzmq::{Context, Msg, Socket, SocketType, ZmqError};

const QUOTES: &str = "tcp://127.0.0.1:5670";

fn text(msg: &Msg) -> String {
    String::from_utf8_lossy(msg.data().unwrap_or_default()).into_owned()
}

async fn serve(server: Socket) -> Result<(), ZmqError> {
    let request = server.recv().await?;
    println!("server got: {}", text(&request));
    server.send(Msg::from_static(b"widget costs 42")).await?;
    Ok(())
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), ZmqError> {
    let ctx = Context::new()?;

    let server = ctx.socket(SocketType::Rep)?;
    server.bind(QUOTES).await?;
    let serving = tokio::spawn(serve(server));

    let client = ctx.socket(SocketType::Req)?;
    let events = client.monitor_default().await?;
    client.connect(QUOTES).await?;

    while let Ok(event) = events.recv().await {
        if let SocketEvent::HandshakeSucceeded { endpoint } = event {
            println!("client handshake done with {endpoint}");
            break;
        }
    }

    client.send(Msg::from_static(b"price of widget?")).await?;
    let reply = client.recv().await?;
    println!("client got: {}", text(&reply));

    serving.await.expect("server task panicked")?;
    ctx.term().await?;
    Ok(())
}

Run it:

$ cargo run
client handshake done with tcp://127.0.0.1:5670
server got: price of widget?
client got: widget costs 42

Anyone on the network path can read those bytes and anyone can connect to that port and ask for a price. The next step fixes the first problem and lets the client prove it is talking to the real server.

Turn on CURVE#

CURVE needs options on both sockets, all set before bind or connect:

  • The server sets CURVE_SERVER to true and gives its own secret key in CURVE_SECRET_KEY.
  • The client gives its own secret key in CURVE_SECRET_KEY and the server's public key in CURVE_SERVER_KEY.

The client uses the server's public key to check that whoever answers holds the matching secret key. The keys go in as &[u8; 32], which set_option accepts directly. A small key helper reads a key file and panics with the path if it is missing or the wrong size.

Replace src/main.rs:

src/main.rs
use rzmq::socket::{CURVE_SECRET_KEY, CURVE_SERVER, CURVE_SERVER_KEY, SocketEvent};
use rzmq::{Context, Msg, Socket, SocketType, ZmqError};

const QUOTES: &str = "tcp://127.0.0.1:5671";

fn key(path: &str) -> [u8; 32] {
    let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("cannot read {path}: {e}"));
    bytes.try_into().expect("a key file holds exactly 32 bytes")
}

fn text(msg: &Msg) -> String {
    String::from_utf8_lossy(msg.data().unwrap_or_default()).into_owned()
}

async fn serve(server: Socket) -> Result<(), ZmqError> {
    let request = server.recv().await?;
    println!("server got: {}", text(&request));
    server.send(Msg::from_static(b"widget costs 42")).await?;
    Ok(())
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), ZmqError> {
    let ctx = Context::new()?;

    let server = ctx.socket(SocketType::Rep)?;
    server.set_option(CURVE_SERVER, true).await?;
    server
        .set_option(CURVE_SECRET_KEY, &key("keys/server.curve.sk"))
        .await?;
    server.bind(QUOTES).await?;
    let serving = tokio::spawn(serve(server));

    let client = ctx.socket(SocketType::Req)?;
    client
        .set_option(CURVE_SECRET_KEY, &key("keys/client.curve.sk"))
        .await?;
    client
        .set_option(CURVE_SERVER_KEY, &key("keys/server.curve.pk"))
        .await?;
    let events = client.monitor_default().await?;
    client.connect(QUOTES).await?;

    while let Ok(event) = events.recv().await {
        if let SocketEvent::HandshakeSucceeded { endpoint } = event {
            println!("client handshake done with {endpoint} (CURVE)");
            break;
        }
    }

    client.send(Msg::from_static(b"price of widget?")).await?;
    let reply = client.recv().await?;
    println!("client got: {}", text(&reply));

    serving.await.expect("server task panicked")?;
    ctx.term().await?;
    Ok(())
}

Run it:

$ cargo run
client handshake done with tcp://127.0.0.1:5671 (CURVE)
server got: price of widget?
client got: widget costs 42

The application code didn't change at all. send and recv look the same and the encryption happens underneath once the handshake is done. That is why waiting for HandshakeSucceeded matters more here: it is the event that tells you the keys checked out.

The server never names any client key. rzmq doesn't support ZAP, the ZeroMQ protocol a server would use to accept or reject clients by public key, so a CURVE server takes any client that completes the handshake. CURVE here gives you an encrypted session and a server the client can trust. If the server also has to know who the client is, do that in your own messages on top.

Point the client at the wrong server key#

To see what failure looks like, make a third keypair that stands in for a server the client shouldn't trust:

rzmq keygen curve --name impostor --output-dir keys --file-format binary
rzmq keygen noise-xx --name impostor --output-dir keys --file-format binary

The real server keeps its real key. The client is told to expect impostor.curve.pk, as if it had been handed a stale or wrong key. There is no point sending anything, so this version only watches the monitor and reports the first handshake outcome.

Replace src/main.rs:

src/main.rs
use rzmq::socket::{CURVE_SECRET_KEY, CURVE_SERVER, CURVE_SERVER_KEY, SocketEvent};
use rzmq::{Context, SocketType, ZmqError};

const QUOTES: &str = "tcp://127.0.0.1:5672";

fn key(path: &str) -> [u8; 32] {
    let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("cannot read {path}: {e}"));
    bytes.try_into().expect("a key file holds exactly 32 bytes")
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), ZmqError> {
    let ctx = Context::new()?;

    let server = ctx.socket(SocketType::Rep)?;
    server.set_option(CURVE_SERVER, true).await?;
    server
        .set_option(CURVE_SECRET_KEY, &key("keys/server.curve.sk"))
        .await?;
    server.bind(QUOTES).await?;

    let client = ctx.socket(SocketType::Req)?;
    client
        .set_option(CURVE_SECRET_KEY, &key("keys/client.curve.sk"))
        .await?;
    client
        .set_option(CURVE_SERVER_KEY, &key("keys/impostor.curve.pk"))
        .await?;
    let events = client.monitor_default().await?;
    client.connect(QUOTES).await?;

    while let Ok(event) = events.recv().await {
        match event {
            SocketEvent::HandshakeSucceeded { endpoint } => {
                println!("client handshake done with {endpoint}");
                break;
            }
            SocketEvent::HandshakeFailed {
                endpoint,
                error_msg,
            } => {
                println!("client handshake with {endpoint} failed: {error_msg}");
                break;
            }
            _ => {}
        }
    }

    ctx.term().await?;
    Ok(())
}

Run it:

$ cargo run
client handshake with tcp://127.0.0.1:5672 failed: Connection closed by peer or transport

The server is the side that finds the key wrong. The client encrypts its first handshake message to the key it was given, the server cannot decrypt it and closes the connection, so all the client learns is that the connection closed. A client that sees this right after connecting should suspect its server key before the network.

When the handshake fails, rzmq drops the connection and no application message crosses it. The client keeps its connect and tries again on its reconnect interval. Each attempt fails the same way until the client has the right key, so a real client should log HandshakeFailed where someone will see it.

ctx.term() still shuts everything down cleanly here, with the client's connection never established and the REP socket never having seen a request.

Switch to Noise_XX#

Noise_XX uses the same shape of options with different names:

  • Both sides set NOISE_XX_ENABLED to true and their own secret key in NOISE_XX_STATIC_SECRET_KEY.
  • The client sets NOISE_XX_REMOTE_STATIC_PUBLIC_KEY to the server's public key.

There is no separate server flag. In the XX handshake both sides send their static public keys encrypted, so the server learns the client's key during the handshake and the client checks the server's against the one it was given.

Replace src/main.rs:

src/main.rs
use rzmq::socket::{
    NOISE_XX_ENABLED, NOISE_XX_REMOTE_STATIC_PUBLIC_KEY, NOISE_XX_STATIC_SECRET_KEY, SocketEvent,
};
use rzmq::{Context, Msg, Socket, SocketType, ZmqError};

const QUOTES: &str = "tcp://127.0.0.1:5673";

fn key(path: &str) -> [u8; 32] {
    let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("cannot read {path}: {e}"));
    bytes.try_into().expect("a key file holds exactly 32 bytes")
}

fn text(msg: &Msg) -> String {
    String::from_utf8_lossy(msg.data().unwrap_or_default()).into_owned()
}

async fn serve(server: Socket) -> Result<(), ZmqError> {
    let request = server.recv().await?;
    println!("server got: {}", text(&request));
    server.send(Msg::from_static(b"widget costs 42")).await?;
    Ok(())
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), ZmqError> {
    let ctx = Context::new()?;

    let server = ctx.socket(SocketType::Rep)?;
    server.set_option(NOISE_XX_ENABLED, true).await?;
    server
        .set_option(NOISE_XX_STATIC_SECRET_KEY, &key("keys/server.noise.sk"))
        .await?;
    server.bind(QUOTES).await?;
    let serving = tokio::spawn(serve(server));

    let client = ctx.socket(SocketType::Req)?;
    client.set_option(NOISE_XX_ENABLED, true).await?;
    client
        .set_option(NOISE_XX_STATIC_SECRET_KEY, &key("keys/client.noise.sk"))
        .await?;
    client
        .set_option(
            NOISE_XX_REMOTE_STATIC_PUBLIC_KEY,
            &key("keys/server.noise.pk"),
        )
        .await?;
    let events = client.monitor_default().await?;
    client.connect(QUOTES).await?;

    while let Ok(event) = events.recv().await {
        if let SocketEvent::HandshakeSucceeded { endpoint } = event {
            println!("client handshake done with {endpoint} (Noise_XX)");
            break;
        }
    }

    client.send(Msg::from_static(b"price of widget?")).await?;
    let reply = client.recv().await?;
    println!("client got: {}", text(&reply));

    serving.await.expect("server task panicked")?;
    ctx.term().await?;
    Ok(())
}

Run it:

$ cargo run
client handshake done with tcp://127.0.0.1:5673 (Noise_XX)
server got: price of widget?
client got: widget costs 42

The wrong-key test works the same way. Take the program from the previous section, swap the CURVE options for the Noise_XX ones on both sockets and give the client impostor.noise.pk:

src/main.rs
use rzmq::socket::{
    NOISE_XX_ENABLED, NOISE_XX_REMOTE_STATIC_PUBLIC_KEY, NOISE_XX_STATIC_SECRET_KEY, SocketEvent,
};
use rzmq::{Context, SocketType, ZmqError};

const QUOTES: &str = "tcp://127.0.0.1:5674";

fn key(path: &str) -> [u8; 32] {
    let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("cannot read {path}: {e}"));
    bytes.try_into().expect("a key file holds exactly 32 bytes")
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), ZmqError> {
    let ctx = Context::new()?;

    let server = ctx.socket(SocketType::Rep)?;
    server.set_option(NOISE_XX_ENABLED, true).await?;
    server
        .set_option(NOISE_XX_STATIC_SECRET_KEY, &key("keys/server.noise.sk"))
        .await?;
    server.bind(QUOTES).await?;

    let client = ctx.socket(SocketType::Req)?;
    client.set_option(NOISE_XX_ENABLED, true).await?;
    client
        .set_option(NOISE_XX_STATIC_SECRET_KEY, &key("keys/client.noise.sk"))
        .await?;
    client
        .set_option(
            NOISE_XX_REMOTE_STATIC_PUBLIC_KEY,
            &key("keys/impostor.noise.pk"),
        )
        .await?;
    let events = client.monitor_default().await?;
    client.connect(QUOTES).await?;

    while let Ok(event) = events.recv().await {
        match event {
            SocketEvent::HandshakeSucceeded { endpoint } => {
                println!("client handshake done with {endpoint}");
                break;
            }
            SocketEvent::HandshakeFailed {
                endpoint,
                error_msg,
            } => {
                println!("client handshake with {endpoint} failed: {error_msg}");
                break;
            }
            _ => {}
        }
    }

    ctx.term().await?;
    Ok(())
}
client handshake with tcp://127.0.0.1:5674 failed: Security error: Noise decrypt/authentication failed (e.g., bad MAC or ciphertext).

Pick CURVE when any peer might be libzmq or another ZeroMQ implementation. Pick Noise_XX when every peer is rzmq and you want the server to learn each client's static key as part of the handshake. The two don't mix: both ends of a connection have to use the same mechanism.

Shut down cleanly#

Every program above ends the same way. The server task returns after its one reply, main awaits it and then ctx.term() closes both sockets and waits for their background tasks:

src/main.rs
serving.await.expect("server task panicked")?;
ctx.term().await?;

For a service that runs until told to stop, the security options don't change anything about shutdown. Load the keys once at startup, set them before bind or connect and keep the monitor running in its own task so a HandshakeFailed reaches your logs whenever a client shows up with the wrong key.

Where to go next#

The rzmq usage guide lists the security options for PLAIN, CURVE and Noise_XX together with the rest of the monitor events. The full API reference, including every option constant and the SocketEvent enum, is on docs.rs.