ztls — Sans-I/O
ztls is a pure TLS 1.3 state machine: you feed it bytes, it gives you bytes back. It does not open sockets, allocate memory, or spawn threads. This document shows how to drive a handshake and exchange application data using the public API.
Mental model
Network bytes (TLS records)
| ^
feed | | drain
v |
+-----+---------+-----+
| |
| Engine (ztls) |
| state machine |
| |
+-----+---------+-----+
| ^
read | | write
v |
Plaintext application data
The engine owns the TLS protocol: framing, encryption, transcript hashing, alerts, and key ratcheting. The caller owns all buffers, all transport I/O, and the drive loop that moves bytes between the two.
Walkthrough: start with these examples
docs/USAGE.md is the reference. If you want executable adoption paths first, read the CI-gated examples:
examples/in_memory_handshake.zig— both engines in one process, no sockets. Read this first: it shows the full 1-RTT handshake and application data in both directions.examples/tcp_loopback.zig— ztls client plus ztls server overstd.net.Streamon loopback.examples/epoll_pingpong.zig— non-blocking Linux epoll client/server ping-pong.examples/iouring_pingpong.zig— Linux io_uring client/server ping-pong.
just examples-ci builds and runs those paths. If a drive-loop shape here diverges from those examples, this document is the stale side.
Fresh project setup
Use Zig 0.15.2 or newer. ztls links a libcrypto-family provider through pkg-config; the repository devshell supplies OpenSSL by default.
Start from Zig’s generated project files so build.zig.zon gets a valid package fingerprint:
mkdir hello-ztls
cd hello-ztls
zig init
Add ztls to the generated build.zig.zon. Keep the fingerprint generated by zig init; Zig validates it. The dependency path must be relative to the consumer project root:
.dependencies = .{
.ztls = .{ .path = "../ztls" },
},
Wire the dependency module into the executable in build.zig:
const ztls_dep = b.dependency("ztls", .{
.target = target,
.optimize = optimize,
});
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{.{ .name = "ztls", .module = ztls_dep.module("ztls") }},
});
const exe = b.addExecutable(.{ .name = "hello-ztls", .root_module = exe_mod });
Then import ztls from application code:
const ztls = @import("ztls");
For a Git dependency, use zig fetch --save git+https://github.com/mattrobenolt/ztls#<commit> and keep the hash Zig writes into build.zig.zon. Do not hand-write dependency hashes.
Supported surface for adopters
ztls is TLS 1.3 only. The supported user-facing path is server-authenticated 1-RTT over caller-owned buffers.
| Area | Supported path | Not covered here |
|---|---|---|
| TLS versions | TLS 1.3 | TLS 1.2 and DTLS are out of scope. |
| Cipher suites | TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256 | Suite expansion is provider work. |
| Key exchange | X25519 or P-256 ECDHE on both client and server; examples use X25519 | P-384 and PQ/hybrid groups are tracked by #6. |
| Authentication | Server certificate authentication | Client certificate auth is tracked by #4. |
| Resumption | None | PSK/session resumption is tracked by #2; 0-RTT is tracked by #3. |
| HRR | Not in the adoption path | HelloRetryRequest retry support is tracked by #1. |
Buffer ownership
- Caller owns every buffer. The engine holds no heap state and never allocates.
out— caller-provided scratch for records the engine emits (ClientHello, Finished, app data, alerts).storage— caller-provided backing forRecordBuffer, which turns a byte stream into whole records.- Records are decrypted in place.
RecordBuffer.next()returns a mutable slice intostorage. Hand it tohandleRecord, which may mutate it during decryption. The slice is valid only until the nextnext()orwritable()call. - Application data returned in
Event.application_datais a slice into that same record buffer. Copy it before the next engine call if you need it longer.
RecordBuffer: stream to record framing
Transports deliver bytes; the engine consumes complete TLS records. RecordBuffer bridges the gap.
var storage: [ztls.RecordBuffer.recommended_storage]u8 = undefined;
var rb: ztls.RecordBuffer = .init(&storage);
// Read transport bytes into the free region.
const n = try stream.read(rb.writable());
if (n == 0) return error.PeerClosed;
rb.advance(n);
// Pull whole records and feed them to the engine.
while (try rb.next()) |record| {
const ev = try hs.handleRecord(record, &out);
// ... handle event
}
writable()compacts unconsumed bytes to the front, then returns the largest contiguous free region.advance(n)reports how many bytes were written.next()returnsnulluntil a full record is buffered. No partial record is ever handed out.recommended_storage = 2 * min_storage(about 33 KiB). This fits a partial record plus a full one, so a read that straddles a boundary still makes progress.
The drive loop
Every connection follows the same pattern, whether client or server:
- Emit — call an engine method that produces bytes (
start,handleRecord,sendApplicationData). - Write — send those bytes to the transport.
- Acknowledge — call
completeWrite()to tell the engine the bytes were sent. - Read — pull more bytes from the transport into
RecordBuffer. - Repeat until connected, then keep repeating for application data.
Client drive loop
var out: ztls.ClientHandshake.OutBuffer = .empty;
var storage: ztls.RecordBuffer.Storage = .empty;
var rb: ztls.RecordBuffer = .init(&storage.buffer);
var random: ztls.Random = undefined;
std.crypto.random.bytes(&random.data);
var hs: ztls.ClientHandshake = .init(.{
.keypairs = .init(keypair),
.host_name = "example.com",
.now_sec = std.time.timestamp(),
.random = random,
});
try stream.writeAll(try hs.start(&out.buffer));
hs.completeWrite();
while (!hs.isConnected()) {
const n = try stream.read(rb.writable());
if (n == 0) return error.ServerClosed;
rb.advance(n);
while (try rb.next()) |record| switch (try hs.handleRecord(record, &out.buffer)) {
.write => |w| {
try stream.writeAll(w);
hs.completeWrite();
},
.application_data, .closed => return error.UnexpectedDuringHandshake,
.none => {},
};
}
Server drive loop
The server loop is identical in shape, with two differences:
ServerHandshake.Configcarries the ServerHello random up front.- Server credentials are configured before the ClientHello arrives, and
sendServerFlightBufferedsends the authenticated server flight after ServerHello is written.
var random: ztls.Random = undefined;
std.crypto.random.bytes(&random.data);
var hs: ztls.ServerHandshake = .init(.{
.keypairs = .init(server_keypair),
.random = random,
.alpn_protocols = &.{"h2"},
});
var signer: ztls.signature.PrivateKey = try .fromP256Scalar(scalar[0..32]);
defer signer.deinit();
hs.setCredentials(&.{cert_der}, signer.signer());
var out: ztls.ServerHandshake.OutBuffer = .empty;
var flight: ztls.ServerHandshake.FlightBuffer = .empty;
var storage: ztls.RecordBuffer.Storage = .empty;
var rb: ztls.RecordBuffer = .init(&storage.buffer);
while (!hs.isConnected()) {
const n = try stream.read(rb.writable());
if (n == 0) return error.ClientClosed;
rb.advance(n);
while (try rb.next()) |record| switch (try hs.handleRecord(record, &out.buffer)) {
.write => |w| {
try stream.writeAll(w);
hs.completeWrite();
if (try hs.sendServerFlightBuffered(&flight)) |flight_bytes| {
try stream.writeAll(flight_bytes);
hs.completeWrite();
}
},
.none => {},
.application_data, .closed => return error.UnexpectedDuringHandshake,
};
}
The pending_write interlock
Every engine method that produces bytes sets an internal pending_write flag. The next engine call returns error.PendingWrite until completeWrite() clears the flag.
This prevents a silent desync: if the caller drops a write (kernel buffer full, async task cancelled, early return), the engine would otherwise advance its sequence numbers while the peer never saw the record. pending_write forces the caller to acknowledge every emitted record before the state machine moves on.
Rules:
- Call
completeWrite()immediately after the bytes are written to the transport. - Never call two engine send-methods in a row without
completeWrite()between them. - In async code,
completeWrite()belongs in the write-completion callback.
For blocking transports, keep it simple:
try stream.writeAll(record);
hs.completeWrite();
For non-blocking transports, use ztls.Outbox to own the unsent tail and call
completeWrite() only after the full TLS record drains:
const Sender = struct {
fd: std.posix.fd_t,
pub fn write(self: Sender, bytes: []const u8) !usize {
return std.posix.send(self.fd, bytes, 0) catch |err| switch (err) {
error.WouldBlock => 0,
else => return err,
};
}
};
var outbox: ztls.Outbox = .init;
const sender: Sender = .{ .fd = fd };
const result = try outbox.send(&hs, record, sender);
// Later, when the fd is writable again:
const next_result = try outbox.flush(&hs, sender);
FlushResult.drained means the record fully drained and completeWrite() was
called; FlushResult.pending means bytes remain queued and the caller should
retry flush when the transport is writable again.
_ = result;
_ = next_result;
Outbox borrows the queued record slice, so the output buffer that produced the
record must stay alive and unchanged until outbox.writeBlocked() is false. The
writer contract is intentionally tiny: write(bytes) returns the number of
bytes accepted, and 0 means no progress / would-block. While an outbox owns a
record, do not call completeWrite() manually; the outbox does that after a full
record drain. The writer must not call back into ztls.
Event union
handleRecord returns an Event:
| Variant | Meaning | When it occurs |
|---|---|---|
.write: []const u8 | A record that must be sent to the peer | Client Finished, KeyUpdate response |
.application_data | Decrypted plaintext from the peer | Connected phase only |
.none | Nothing to send; state advanced internally | ChangeCipherSpec discarded, flight partial |
.closed | Peer sent close_notify | Any phase |
.application_data during the handshake is an error (UnexpectedDuringHandshake) because application data must not arrive before the handshake completes.
kTLS key export
After the handshake is connected, txKtlsInfo() and rxKtlsInfo() return copied Linux kTLS key material for the current traffic-key epoch. tx is the local write direction; rx is the peer write direction. The core still does no socket I/O and does not call setsockopt.
For caller-initiated KeyUpdate, send the KeyUpdate record, call completeWrite() after it is fully written, then call txKtlsInfo() and install that value as the new kernel TX key before the next kernel send. For inbound KeyUpdate, the engine-owned record path ratchets rx; callers using kernel-owned RX must treat this as a sequencing contract they own and install the new rxKtlsInfo() value when they process a KeyUpdate boundary. Full kTLS RX event surfacing remains a separate API concern; do not poll once at handshake completion and assume the epoch is permanent.
Server credentials
Unlike the client, which only needs a certificate policy, the server must send a certificate chain and sign the CertificateVerify message. Configure that before processing the ClientHello:
var signer: ztls.signature.PrivateKey = try .fromP256Scalar(scalar[0..32]);
defer signer.deinit();
server.setCredentials(&.{leaf_cert_der}, signer.signer());
The certificate chain is a DER slice list in leaf-first order. sendServerFlightBuffered uses the configured credentials, owns the authenticated-flight one-shot latch, and returns null if there is no server flight to send.
var flight: ztls.ServerHandshake.FlightBuffer = .empty;
if (try server.sendServerFlightBuffered(&flight)) |flight_bytes| {
try stream.writeAll(flight_bytes);
server.completeWrite();
}
sendAuthenticatedFlight remains available as a lower-level escape hatch, but new callers should prefer up-front setCredentials plus sendServerFlightBuffered. PrivateKey.deinit() zeroes key material via libcrypto.
SNI (server name indication)
After handleRecord returns the first .write event (the ServerHello), the server can read the hostname the client requested:
if (server.clientServerName()) |name| {
// select certificate based on `name`
}
clientServerName() returns null if the client sent no server_name extension. The slice points into the caller’s record buffer; copy it if you need it past the next handleRecord call.
Virtual hosting pattern: call
handleRecordfor the ClientHello, inspectclientServerName(), select the appropriate keypair/cert, callsetCredentials, then send the authenticated flight withsendServerFlightBuffered.
ALPN
Both sides offer protocol lists before the handshake begins:
// Client — via Config at init time
var hs: ztls.ClientHandshake = .init(.{
.keypairs = .init(keypair),
.host_name = "example.com",
.now_sec = std.time.timestamp(),
.random = random,
.alpn_protocols = &.{ "h2", "http/1.1" },
});
// or after init:
// client.offerAlpn(&.{ "h2", "http/1.1" });
// Server
server.supportAlpn(&.{"h2"});
After the handshake, selectedAlpnProtocol() returns the negotiated protocol (or null if none was agreed). The server picks the first entry from its list that the client also offered; if both sides sent ALPN but no protocol matches, acceptClientHello returns error.NoApplicationProtocol. The client rejects a server-selected protocol that was not offered (error.UnofferedAlpnProtocol).
Certificate policy
The client validates the server certificate chain against a caller-owned policy.
The Config struct seeds policy at init time from host_name, now_sec,
bundle, and insecure_no_chain_anchor; policy remains public for advanced
overrides after init:
var hs: ztls.ClientHandshake = .init(.{
.keypairs = .init(keypair),
.host_name = "example.com", // SAN/CN check + SNI
.now_sec = std.time.timestamp(), // validity-period check
.bundle = &bundle, // trust-anchor anchoring
.random = random,
});
// Advanced override (optional):
// hs.policy.insecure_no_chain_anchor = true; // test/demo only
A client policy without bundle rejects the server Certificate unless the
caller explicitly sets insecure_no_chain_anchor = true for a test/demo
fixture. The bundle type is Zig’s std.crypto.Certificate.Bundle; load it from
the trust anchors appropriate for your application and keep it caller-owned for
the connection lifetime. The insecure fixture mode still verifies
CertificateVerify key possession, but it does not authenticate the chain to any
trust root.
Close semantics
A clean close is a bidirectional close_notify alert exchange (RFC 8446 §6.1). Send one when you’re done:
const rec = try engine.sendAlert(.close_notify, &out);
try stream.writeAll(rec);
engine.completeWrite();
When the peer sends close_notify, handleRecord returns .closed. Receiving .closed does not automatically send a close_notify back — the caller decides whether to half-close, reply in kind, or simply drop the connection.
Fatal alerts (decode_error, unexpected_message, etc.) are sent the same way but always at fatal level:
const rec = try engine.sendAlert(.decode_error, &out);
try stream.writeAll(rec);
// don't call completeWrite; treat the connection as dead
Before the handshake is encrypted (.wait_ch state), sendAlert emits a plaintext alert record. Once handshake keys are installed, all alerts are encrypted.
Buffer sizing
| Buffer | Minimum recommended | Why |
|---|---|---|
out | 4 KiB | Fits a full record plus handshake overhead |
storage | RecordBuffer.recommended_storage (~33 KiB) | Fits a partial + full record |
flight | ServerHandshake.FlightBuffer | Holds the encrypted server authenticated flight |
The engine returns error.BufferTooShort if out is too small. Use RecordBuffer.recommended_storage for storage; anything smaller risks stalling on a large record.
API reference
This is a consumer index for the public symbols used by the guide and examples. The walkthrough sections above are the canonical explanation of the drive loop.
ClientHandshake
Caller-owned types:
ClientHandshake.OutBuffer— scratch for ClientHello, Finished, application data, alerts, and KeyUpdate records. Use.emptyfor the default stack-backed shape.ClientHandshake.Storage— optional handshake-message reassembly storage. Use.emptyand pass&storage.buffertouseHandshakeBufferfor unusual chain sizes or tighter memory control.
Common drive methods:
init(config)/deinit()— create and release a client handshake.Configrequireskeypair,host_name,now_sec, andrandom; optional fields defaultbundle,insecure_no_chain_anchor,alpn_protocols, andreassembly.offerAlpn(protocols)— advertise application protocols beforestart(also settable viaConfig.alpn_protocols).useHandshakeBuffer(storage)— attach caller-owned handshake reassembly storage (also settable viaConfig.reassembly).start(out)— emit ClientHello usingConfig.host_name(SNI) andConfig.random.handleRecord(record, out)— consume one TLS record fromRecordBuffer.next()and returnEvent.isConnected()— true after the server Finished verifies and application keys are installed.sendApplicationData(plaintext, out)/sendPreparedApplicationData(len, out)— emit one encrypted application-data record.sendAlert(description, out)— emitclose_notifyor a fatal alert.sendKeyUpdate(request, out)— emit a post-handshake KeyUpdate.txKtlsInfo()/rxKtlsInfo()— copy current traffic-key epoch material for caller-owned Linux kTLS setup.completeWrite()— acknowledge the previous emitted record.selectedAlpnProtocol()— negotiated ALPN protocol, ornull.
Policy fields (seeded from Config at init, overridable after):
| Field | Config field | Effect |
|---|---|---|
policy.host_name | Config.host_name | Expected DNS name for SAN/CN verification + SNI. |
policy.bundle | Config.bundle | Caller-owned trust-anchor bundle. |
policy.now_sec | Config.now_sec | Validity-period timestamp. |
policy.insecure_no_chain_anchor | Config.insecure_no_chain_anchor | Test/demo opt-out from trust-anchor verification. |
Low-level in-memory hooks exist for fixture-style handshakes with no transport between engines: processServerHello, processFlight, and clientFinished. Prefer the normal start + handleRecord loop for transport integrations.
ServerHandshake
Caller-owned types:
ServerHandshake.OutBuffer— scratch for ServerHello, application data, alerts, and KeyUpdate records.ServerHandshake.FlightBuffer— scratch for the encrypted authenticated server flight. Reuse one buffer;sendServerFlightBufferedowns the one-shot latch.ServerHandshake.Storage— optional ClientHello reassembly storage. Use.emptyand pass&storage.buffertouseHandshakeBuffer.
Common drive methods:
init(Config)/deinit()— create and release a server handshake. Required Config fields arekeypairsandrandom; optional fields includesupported_suites,alpn_protocols,client_auth, and ClientHelloreassemblystorage.supportSuites(suites)— override the Config-provided cipher-suite list before processing ClientHello.supportAlpn(protocols)— override the Config-provided ALPN choices before processing ClientHello.setCredentials(certs, signer)/setCertificateChain(chain, signer)— attach a leaf-first certificate chain and signer before processing ClientHello.useHandshakeBuffer(storage)— attach or override caller-owned ClientHello reassembly storage before processing ClientHello.handleRecord(record, out)— consume one TLS record fromRecordBuffer.next()and returnEvent.sendServerFlightBuffered(flight)/sendPreparedServerFlight(out)— emit the authenticated server flight after ServerHello is written and acknowledged.needsServerFlight()— true while the authenticated flight still needs to be sent.isConnected()— true after the client Finished verifies and application keys are installed.clientServerName()— SNI hostname, ornull.selectedAlpnProtocol()— negotiated ALPN protocol, ornull.sendApplicationData(plaintext, out)/sendPreparedApplicationData(len, out)— emit one encrypted application-data record.sendAlert(description, out)andsendKeyUpdate(request, out)— post-handshake emits.txKtlsInfo()/rxKtlsInfo()— copy current traffic-key epoch material for caller-owned Linux kTLS setup.completeWrite()— acknowledge the previous emitted record.
Low-level in-memory hooks used by examples/in_memory_handshake.zig: acceptClientHello, processClientFinished, and receiveApplicationData.
Outbox
Outboxis optional glue for non-blocking or partial-write transports.send(hs, record, writer)queues one engine-produced record and flushes as much as the writer accepts.flush(hs, writer)resumes a partial write.FlushResult.drainedmeans the full record drained andcompleteWrite()was called;FlushResult.pendingmeans retry later.writeBlocked()is true while one record is still pending; do not call another record-producing engine method while blocked.- The writer adapter must expose
write(bytes) !usize; returning0means no progress, not success. - The queued record is borrowed. Keep the backing
OutBuffer/FlightBufferstable until drained.
RecordBuffer
RecordBuffer.StorageandRecordBuffer.MinStorageare stack-friendly storage wrappers.RecordBuffer.recommended_storageis the normal stream buffer size;min_storagefits one maximum wire record.init(storage)binds caller-owned storage.writable()returns the slice to fill from the transport and may compact buffered bytes.advance(n)reports how many bytes the transport wrote intowritable().next()returns the next complete TLS record, ornullif the buffer only holds a partial record.
Signing and key exchange
signature.PrivateKey.fromPem(scheme, pem)andfromDer(scheme, der)load private keys from caller-owned bytes.signature.PrivateKey.fromP256Scalar(scalar)is useful for fixtures and examples that carry a raw P-256 scalar.signature.PrivateKey.signer()borrows asignature.Signervtable forsetCredentials.signature.PrivateKey.deinit()releases libcrypto key material.SignatureSchemenames the TLS signature scheme used with a loaded key, includingrsa_pss_rsae_sha256,ecdsa_secp256r1_sha256,ecdsa_secp384r1_sha384, anded25519.x25519.KeyPair.generate()creates a fresh ephemeral X25519 keypair.x25519.KeyPair.generateDeterministic(seed)andx25519.sharedSecret(secret_key, peer_public_key)are lower-level primitives for tests and fixed-vector paths.
Runtime-specific integration notes
The generic drive loop above is the protocol boundary. These notes call out what changes by transport model; the named files are built by just examples-ci.
Blocking std.net.Stream
examples/tcp_loopback.zig drives a ztls client and ztls server over loopback TCP. Blocking I/O keeps the ownership rules boring: read into RecordBuffer.writable(), feed each complete record to the engine, writeAll each emitted record, then call completeWrite().
In-memory transport
examples/in_memory_handshake.zig connects both engines by passing record slices directly. It uses low-level hooks such as acceptClientHello, processServerHello, processClientFinished, and receiveApplicationData because there is no byte-stream transport to frame with RecordBuffer.
epoll
examples/epoll_pingpong.zig is Linux-only and non-blocking. It uses ztls.Outbox for the write side: keep at most one engine-emitted record queued, wait for writability if the socket blocks, and do not call another record-producing engine method until the outbox drains. Reads still fill RecordBuffer.writable() and records still flow through handleRecord one at a time.
io_uring
examples/iouring_pingpong.zig is Linux-only. The setup path may use ordinary blocking listen / accept / connect; the TLS record data path submits sends and receives through io_uring. The same pending-write rule applies: every record emitted by the engine must complete through the ring before the next engine call that can advance protocol state.
What is not shown here
ztls focuses on TLS 1.3 server-auth 1-RTT. These features are intentionally out of scope for the examples above:
- Client certificate authentication (#4)
- 0-RTT / early data (#3)
- PSK / session resumption (#2)
- HelloRetryRequest retry support (#1)
- P-384 and PQ/hybrid key shares (#6)
The RecordBuffer + handleRecord pattern is the same for every supported flow; higher-level wrappers (async runtimes, std.net.Stream adapters) belong in separate packages.