-
Notifications
You must be signed in to change notification settings - Fork 5
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.
Two reserved packets, exchanged before anything of yours can travel:
| Packet | Id | Carries |
|---|---|---|
HandshakePacket |
(PacketId)(-2) |
A DH public key, the encryption flag and the compression type byte |
ConnectionReadyPacket |
(PacketId)(-3) |
A fixed magic string; a mismatch closes the session |
PacketId is a uint64_t, so those two spellings are the top of its range
rather than actual negative numbers, which is what keeps them clear of the ids
an application hands out.
The initiator sends its HandshakePacket first and always offers a key, so the
server may pick either mode without a second round trip. Only the server's copy
of the flag and the compression byte means anything: the initiator's carries
defaults and is ignored. The server states its decision, including a key when it
chose to encrypt, and both sides expand the resulting secret into their
directional keys. Each side then sends a ConnectionReadyPacket, and receiving
one completes the exchange.
A server that requires encryption but receives a handshake with no key closes the session. So does an initiator whose server chose encryption and then sent no key of its own. Neither falls back to plaintext.
The encryption layer installs a Codec and a packet handler of its own on the
session when the session is constructed, which is how the two packets above are
routed while no application codec exists. When the exchange completes it clears
both, with SetCodec(nullptr) and SetHandler(nullptr), and only then calls
Ready().
So a codec or handler set before the session is ready is discarded, and
SendPacket refuses with Result::NotReady until the same moment. Both belong
in the connect event, which fires after Ready(); see Events.
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.
The encryption cannot be stripped off mid-session either. A plaintext message arriving once the keys exist is dropped rather than delivered, and the retired unauthenticated AES-CBC mode is refused outright rather than accepted for compatibility with an older znet.
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.
It returns Result::Success with out filled, and leaves out untouched
otherwise. Result::Failure means there is no settled exchange to bind to,
either because the session is unencrypted or because it is not yet ready.
Result::InvalidArgument means the label is empty or longer than
EncryptionLayer::kMaxExportLabelLength, or out_len is zero or larger than
EncryptionLayer::kMaxExportLength. 32 bytes is the usual ask.
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, which the build
signals by defining ZNET_USE_ZSTD=1; if none is found, CMake reports zstd not found, compression disabled and Default resolves to None. The define comes
from znet's own CMake, not from you. ZNET_USE_EXTERNAL_ZSTD selects which
zstd, not whether to use one: OFF, the default, uses the vendored copy, and
ON requires an installed one through find_package.
An explicit Zstandard on a build without zstd warns once and sends
uncompressed rather than failing. An inbound zstd message on such a build cannot
be decompressed and is dropped, which is why Default exists.
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. The type is one byte in front of every payload, uncompressed ones included, so the marker costs the same whatever the setting. Setting the threshold to zero compresses everything, which is almost always worse.
The threshold is measured on the serialized payload, before compression, so it is the size your serializer produced rather than the size that would go on the wire.
Outgoing:
your packet -> serialize -> compress (if over threshold) -> encrypt -> transport
Incoming decrypts and then decompresses. Compression sits inside encryption, so an observer sees your data compressed and then encrypted, never the plaintext.
Every message carries a compression type byte and an encryption mode byte, whatever the settings. An encrypted one adds a stream byte and a 7-byte counter in front of the ciphertext and a 16-byte tag behind it, which is the 24 bytes above.
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, run those sessions with
compression = CompressionType::None. There is no per-message override:
PeerSession::SetOutCompression changes the setting for everything sent after
it, and belongs on the session's own thread like the codec and the handler.
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