Skip to content

Encryption and Compression

irrld edited this page Aug 20, 2026 · 5 revisions

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;
}

Who decides

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.

Encryption

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.

The handshake

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.

It owns the codec and the handler until it is done

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.

What it gives you, precisely

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.

What to do about it

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.

Binding a credential to the session

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:

  1. An authentication service issues the client a short-lived token naming a client public key, signed by the service.
  2. The client calls ExportKeyingMaterial and signs the result with the matching private key.
  3. 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.

Compression

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.

The threshold

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.

Ordering in the pipeline

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.

Verifying it is on

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

Clone this wiki locally