ZeroMQ’s CURVE encryption mechanism is so robust that it’s often implemented without ever needing a custom certificate authority, relying instead on simple key distribution.
Let’s watch CURVE in action. We’ll set up a simple request-reply pair, one side encrypted, the other not (yet), then secure both ends.
First, the unencrypted server:
import zmq
context = zmq.Context()
socket = context.socket(zmq.REP)
socket.bind("tcp://*:5555")
while True:
message = socket.recv()
print(f"Received request: {message.decode()}")
socket.send(b"World")
And the unencrypted client:
import zmq
context = zmq.Context()
socket = context.socket(zmq.REQ)
socket.connect("tcp://localhost:5555")
for request in range(10):
print(f"Sending request {request}...")
socket.send(b"Hello")
message = socket.recv()
print(f"Received reply {request}: {message.decode()}")
Running this, you’ll see the familiar "Hello" and "World" exchange. Now, let’s add CURVE.
CURVE uses public-key cryptography. Each side generates a pair of keys: a public key (which can be shared) and a private key (which must be kept secret). The public key is used to verify the identity of the other party, and the private key is used to sign messages.
To enable CURVE, we need to:
- Generate key pairs for both the server and the client.
- Configure the sockets with their respective secret keys and the public keys of the peers they wish to connect to.
Let’s generate keys. We can use curve_keygen from the ZeroMQ distribution, or programmatically. For this example, we’ll generate them manually using a simple script or command.
On your terminal, run:
curve_keygen > server.key
curve_keygen > client.key
This creates two files, server.key and client.key. Each file contains a public and private key pair. The format is typically:
<public_key>;<secret_key>
Now, let’s modify the server to use its key and expect the client’s public key.
Server (with CURVE):
import zmq
import os
context = zmq.Context()
socket = context.socket(zmq.REP)
# Load server's secret key
server_secret_key = open("server.key", "r").read().strip().split(';')[1]
socket.setsockopt_string(zmq.CURVE_SECRETKEY, server_secret_key)
# Load client's public key
client_public_key = open("client.key", "r").read().strip().split(';')[0]
socket.setsockopt_string(zmq.CURVE_PUBLICKEY, client_public_key)
# Set the server's identity (optional, but good practice)
socket.setsockopt_string(zmq.IDENTITY, "SERVER")
socket.bind("tcp://*:5555")
print("Server started, waiting for connections...")
while True:
message = socket.recv()
print(f"Received request: {message.decode()}")
socket.send(b"World Secured")
Client (with CURVE):
import zmq
import os
context = zmq.Context()
socket = context.socket(zmq.REQ)
# Load client's secret key
client_secret_key = open("client.key", "r").read().strip().split(';')[1]
socket.setsockopt_string(zmq.CURVE_SECRETKEY, client_secret_key)
# Load server's public key
server_public_key = open("server.key", "r").read().strip().split(';')[0]
socket.setsockopt_string(zmq.CURVE_PUBLICKEY, server_public_key)
# Set the client's identity (optional)
socket.setsockopt_string(zmq.IDENTITY, "CLIENT")
socket.connect("tcp://localhost:5555")
print("Client connected, sending requests...")
for request in range(10):
print(f"Sending request {request}...")
socket.send(b"Hello Secured")
message = socket.recv()
print(f"Received reply {request}: {message.decode()}")
When you run these modified scripts, you’ll see the same "Hello Secured" and "World Secured" messages. The magic happens in the setsockopt_string calls. zmq.CURVE_SECRETKEY provides the private key for signing, and zmq.CURVE_PUBLICKEY provides the other party’s public key for verification. When the client connects, it sends its public key. The server, having been configured with the client’s public key, verifies it. Similarly, when the server responds, the client verifies the server’s identity using the public key it was configured with.
The beauty here is that you’re not managing certificates or a CA. You’re simply exchanging public keys. The client knows exactly which server it wants to talk to (by its public key), and the server knows exactly which client it expects to talk to (by its public key). This prevents man-in-the-middle attacks because an attacker can’t impersonate the server (they don’t have the server’s private key) or the client (they don’t have the client’s private key).
A common pitfall is mixing up which public key goes where. socket.setsockopt_string(zmq.CURVE_PUBLICKEY, ...) on the client should be set to the server’s public key, and vice-versa on the server. If the public keys don’t match what the socket expects, the connection will fail silently or with a zmq.ZMQError: Operation cannot be accomplished in current state if you try to send/receive before the handshake is complete.
The zmq.CURVE_SERVER option, when set to True on the server socket and False on the client socket, is crucial. If you omit it or set it incorrectly, the handshake might not complete properly, leading to stalled connections. The server needs to know it’s the one initiating the authentication handshake as the "server" role.
With CURVE enabled, if you try to connect a client without CURVE enabled to a server with CURVE enabled (or vice-versa), the connection will fail. This is because the handshake protocol is incompatible. The client will likely receive a zmq.ZMQError: Connection refused or a similar network-level error, as the ZeroMQ endpoint on the other side will reject the connection due to the protocol mismatch.
Once your CURVE connections are established and working, the next challenge is handling dynamic peer discovery and key management in a distributed system.