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.
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:
[]
= { = "0", = ["curve", "noise_xx"] }
= { = "1", = ["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 (
curvefeature) is the CurveZMQ protocol. It encrypts the session, authenticates the server to the client and interoperates with libzmq's CURVE. - Noise_XX (
noise_xxfeature) 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:
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.
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:
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:
use SocketEvent;
use ;
const QUOTES: &str = "tcp://127.0.0.1:5670";
async
async
Run it:
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_SERVERtotrueand gives its own secret key inCURVE_SECRET_KEY. - The client gives its own secret key in
CURVE_SECRET_KEYand the server's public key inCURVE_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:
use ;
use ;
const QUOTES: &str = "tcp://127.0.0.1:5671";
async
async
Run it:
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:
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:
use ;
use ;
const QUOTES: &str = "tcp://127.0.0.1:5672";
async
Run it:
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_ENABLEDtotrueand their own secret key inNOISE_XX_STATIC_SECRET_KEY. - The client sets
NOISE_XX_REMOTE_STATIC_PUBLIC_KEYto 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:
use
;
use ;
const QUOTES: &str = "tcp://127.0.0.1:5673";
async
async
Run it:
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:
use
;
use ;
const QUOTES: &str = "tcp://127.0.0.1:5674";
async
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:
serving.await.expect?;
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.
