-
Notifications
You must be signed in to change notification settings - Fork 4
Encryption and Compression
Both are on by default, both are negotiated during the handshake, and both are decided by one side rather than agreed between them. Neither depends on the transport: they sit above the session and work identically on ZDT and on TCP.
void ConfigureSecurity() {
ServerConfig config{"0.0.0.0", 25000};
// read only on the accepting side. the server announces its choice during
// the handshake and the client adopts it, so a client cannot downgrade a
// server that requires encryption.
config.child_options.common.encryption = true;
// negotiated the same way. runs before encryption, so it compresses the
// plaintext and works on encrypted and unencrypted sessions alike.
config.child_options.common.compression = CompressionType::Zstandard;
// below this, compressing costs more than it saves: at 64 bytes zstd makes
// most traffic larger, and still pays for building the coder tables.
config.child_options.common.compression_threshold = 128;
}The accepting side. On a server that is child_options; setting
encryption in a client's ClientConfig::options has no effect, because the
server announces its choice during the handshake and the client adopts it.
The practical consequence: a client cannot downgrade a server that requires
encryption. A server with encryption = true will not serve an unencrypted
session, whatever the client asks for.
For a P2P pair the dialer marks exactly one peer as the initiator, so the other
one decides. p2p::IsInitiator tells you which you are.
A 2048-bit Diffie-Hellman exchange during the handshake, then AES-256-GCM on every message. Handled entirely inside the session: no keys to manage, no certificates to install, nothing to call.
The exchange is ephemeral: keys exist only for the lifetime of the session and are derived per direction, so the two halves of a connection never share one. Encrypting adds 24 bytes per message over an unencrypted session, and the ciphertext is the same length as its input.
Turn it off only on an already-trusted transport, or to measure what the crypto costs. On the benchmark machine it costs roughly 1.1x at 8 KiB payloads.
Confidentiality against a passive observer. Someone capturing traffic cannot read it, and cannot read it later either: nothing on disk decrypts a recorded capture once the session has ended.
Integrity and authenticity against anyone without the session key. GCM authenticates every message, so ciphertext altered in flight fails its tag and is dropped rather than decrypted and delivered. Replays are dropped too: each message carries a counter, and one already seen is refused. A message that reaches your handler arrived exactly once, unmodified, from whoever holds the session key.
Counters run per independently-ordered stream, each with its own window of the last 64, which is what lets reordered messages through. On ZDT that stream is the channel; on TCP there is one ordered pipe and so one counter. Separate windows matter because unreliable sends may arrive out of order, and a channel waiting on a retransmit can fall arbitrarily far behind one that is still delivering, so a shared window would refuse the stalled channel's messages as too old. Within a single stream, a message reordered by more than 64 behind that stream's newest arrival cannot be proven unseen and is dropped as though the network had lost it; ordinary reordering inside one stream is nowhere near that wide.
That last clause is the gap, and it is worth understanding before relying on this for anything that matters.
No peer authentication. The Diffie-Hellman exchange is unsigned: there are no certificates, no public-key pinning, and no identity check of any kind. It protects against someone listening, not against someone positioned between you, who can complete a separate exchange with each side and hold a valid session key to both. Against that attacker the integrity guarantee above says only "unmodified since the interceptor sent it".
So this is not TLS and should not be treated as equivalent to it. What it defeats is observation and tampering by anyone off the path; what it does not establish is who is on the other end.
For a game talking to your own server over the internet, this is usually acceptable: it defeats casual packet sniffing and packet editing, which is the realistic threat.
If you need more:
- Authenticate at the application layer. Send a token in your first packet and check it before swapping in your real handler. On its own this tells you who the peer claims to be and nothing more: an interceptor holds the session key on the client's side, so it reads the token and replays it onward, and the server accepts it as genuine. Bind it to the session to close that, below.
- For anything genuinely sensitive, meaning credentials, payment data or personal information, run znet inside a transport that authenticates, or do not send it over znet.
- Validate what you deserialize anyway. A packet that authenticates proves the sender held the session key, not that its contents make sense. A compromised or malicious peer is still a peer.
PeerSession::ExportKeyingMaterial(label, out, out_len) derives bytes from the
key exchange, over a transcript of both public keys. Both ends of a session get
the same bytes for the same label, nobody else can compute them, and every
session gets different ones.
That last property is what a bearer token lacks. An interceptor runs two separate exchanges, one with each end, so it holds two different exported values and cannot make a proof built on one satisfy the other. A credential that covers the export is worthless on any session but the one it was made for.
The shape this is for, with the token format and the service left to you:
- An authentication service issues the client a short-lived token naming a client public key, signed by the service.
- The client calls
ExportKeyingMaterialand signs the result with the matching private key. - The server verifies the token against the service's public key, which is all it needs to hold, then verifies the signature against its own export of the same label.
A MITM can still present itself as a server to a client, since nothing authenticates the server. What this stops is impersonating a player to a server without holding that player's key, which is the usual reason to want it.
Never send the exported value. It is a shared secret, and a listener who learns it can produce whatever proof was built on it. Pick a label unique to your protocol and put a version in it, so the scheme can change later. The call returns false on an unencrypted or not-yet-ready session.
zstd, applied to outgoing messages once the session is ready. It runs before encryption, so it compresses the plaintext, which is what makes it effective; compressing ciphertext would achieve nothing.
| Value | Effect |
|---|---|
CompressionType::Default |
Whatever the build supports. Never appears on the wire |
CompressionType::Zstandard |
zstd |
CompressionType::None |
Off |
Compression is compiled in whenever a zstd target is available; if none is found,
CMake reports zstd not found, compression disabled and Default resolves to
None. ZNET_USE_EXTERNAL_ZSTD selects which zstd, not whether to use one.
compression_threshold (128 bytes) exists because small messages cannot pay
back the frame header. At 64 bytes zstd makes essentially every kind of traffic
about 12% larger, and still costs a full pass to build the coder tables.
Measured break-even is near 96 bytes for text and 128 for binary game state, so
the default sits where compressing stops being actively harmful.
The compression type is recorded per message, so one session freely mixes compressed and uncompressed messages, with no renegotiation and no cost to crossing the threshold in either direction. Setting it to zero compresses everything, which is almost always worse.
Outgoing:
your packet -> serialize -> compress (if over threshold) -> encrypt -> transport
Incoming reverses it. Compression sits inside encryption, so an observer sees your data compressed and then encrypted, never the plaintext.
What an observer does still see is length. The ciphertext is exactly as long
as the compressed plaintext, so message sizes reveal how well your traffic
compressed. That matters only if attacker-influenced content shares a message
with something secret, where the size becomes a hint about the secret; if that
describes your traffic, set compression = CompressionType::None for those
messages.
There is no "is encryption on" flag to read. If you need certainty, run one
session with encryption = false and compare the byte counters, or take a
capture:
SessionMetrics m = session->metrics();
m.common.message_bytes_sent; // after encode, before transport framing
m.common.wire_bytes_sent; // including transport framing