Skip to content

Encryption and Compression

irrld edited this page Jul 30, 2026 · 4 revisions

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.

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-CBC on every message. Handled entirely inside the session — no keys to manage, no certificates to install, nothing to call.

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.

What it gives you, precisely

Confidentiality against a passive observer. Someone capturing traffic cannot read it.

That is the whole of it, and the two gaps below are worth understanding before you rely 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 read everything.

No integrity protection. CBC mode is not authenticated and znet adds no MAC, so modified ciphertext is decrypted and delivered rather than rejected. An attacker who can alter bytes in flight can corrupt your messages undetectably at the transport layer. ZDT's checksums catch accidental corruption, not deliberate tampering.

Despite the README's wording, this is not TLS and should not be treated as equivalent to it.

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, 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. This tells you who the peer is, though it does not fix the MITM gap on its own — an interceptor can relay the token.
  • For anything genuinely sensitive — credentials, payment data, personal information — run znet inside a transport that authenticates, or do not send it over znet.
  • Do not rely on message integrity. Validate what you deserialize. A packet that decrypts is not a packet you can trust.

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

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 — there is no renegotiation and no cost to crossing the threshold in either direction. Setting it to zero compresses everything, which is almost always worse.

Ordering in the pipeline

Outgoing:

your packet -> serialize -> compress (if over threshold) -> encrypt -> transport

Incoming reverses it. Compression sits inside encryption, so an observer sees neither your data nor its compressibility.

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