ztls
ztls is a TLS 1.3 library that does no I/O. You feed it the bytes you read off the wire; it hands you back the bytes to write. Your socket, your event loop, your buffers. ztls just does the protocol.
It’s pre-alpha. The API will change out from under you. Read the guide before you build on it.
This site has four parts:
- Why ztls — what it’s for, when to reach for it, and when to use something mature instead.
- Guide — the buffer-ownership model, the drive loop, and working client and server code.
- API reference — the generated Zig documentation for every public declaration.
- Security — status, scope, and how to report a vulnerability.
The source, issues, and status dashboard live on GitHub.
Why ztls
The README tells you what ztls is. This is the longer answer to why it exists and when you’d reach for it over the library you already have.
Short version: ztls is a TLS 1.3 state machine that does no I/O and allocates nothing of its own, aimed at people who want to own their event loop and their memory. If that’s not you, one of the mature libraries is the right call, and this page will tell you so.
The problem with a TLS library that owns its sockets
Most TLS libraries come welded to an I/O model. OpenSSL wants its BIOs. A typical language-native library wants a blocking stream, or its async runtime, or a callback shape it defines. The moment you pull it in, its assumptions about how bytes move become your assumptions.
That’s fine until it isn’t. If you’re writing an io_uring server, or running on a custom event loop, or building something where every allocation is accounted for, a library that owns its sockets fights you the whole way. You end up wrapping, shimming, and working around a design that assumed it was in charge.
Sans-I/O flips that. The protocol is a pure function of bytes: you give it what came off the wire, it tells you what to send back and hands you decrypted application data. It never touches a socket. It never blocks. It has no thread of its own. Where the bytes come from and where they go is entirely yours.
rustls proved this model works in production. ztls takes it further on memory: no allocator in the engine at all.
What ztls is for
Reach for ztls when:
- You own your I/O and want to keep owning it. epoll, io_uring, a green-thread scheduler, an in-memory test harness — ztls slots into any of them because it has no opinion about how you run.
- You care about allocation. The engine holds no heap state and never calls an allocator. You give it buffers; it uses exactly those. That makes memory behavior something you can reason about instead of profile after the fact.
- You’re on Zig and don’t want to drag a C library’s I/O assumptions into your build, even though the primitive crypto underneath is still battle-tested libcrypto.
- You want TLS 1.3 and nothing else. No 1.2 downgrade paths, no DTLS, no Windows portability layer diluting the code you’re trusting with your traffic.
What ztls is not for, yet
Being honest about this is the whole brand, so here it is straight:
- Anything in production. It’s pre-alpha and unaudited.
SECURITY.mdsays this plainly. - A drop-in OpenSSL replacement. ztls does the TLS state machine. It does
not manage sockets, load OS trust stores, or hand you a
connect(). You write the drive loop. The examples show how, but it’s your code. - The full TLS feature set. Server auth works today. Client certificates, session resumption, 0-RTT, and HelloRetryRequest are tracked as open issues, not shipped.
- A guaranteed-stable API. Signatures move. Pin a commit if you build on it.
If you need a mature, audited, drop-everything-in TLS stack right now, use rustls or OpenSSL. That’s not a dodge — it’s the correct answer for most people today, and ztls will tell you when that changes.
The performance argument
Performance is the reason ztls is being built rather than the reason to adopt it today. The claim is narrow and evidence-backed: on in-memory application-data benchmarks, ztls does less work per record than libssl across the board, and less than rustls on AES-GCM. It loses to rustls on small ChaCha20-Poly1305 records, and that row is published with the disassembly that explains why.
The honest caveats are in the
README and in
docs/research/PERFORMANCE.md: two
x86_64 EC2 shapes so far, no repetition or threshold policy yet, and a
measurement-shape difference between the harnesses. So the current numbers are
measurements, not a marketing headline. When the methodology is hardened enough
to make a headline claim, we’ll make it, and it’ll point at reproducible numbers.
Until then the argument for ztls is architectural: the Sans-I/O, zero-allocation design is what makes the performance ceiling high. The numbers are catching up to the design, in the open, with the losses shown.
Where to go next
README.md— what it is and how to start.docs/USAGE.md— the API guide and drive-loop patterns.docs/research/PERFORMANCE.md— benchmark methodology and the raw captures.docs/research/THREAT_MODEL.md— the attacker model and the caller/engine line.PRODUCTION_READINESS.md— what’s proven, what isn’t, and how we know.
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.
API reference
The full API reference is generated from the source with Zig’s documentation tooling, so it never drifts from the code:
Every public declaration in src/root.zig and its imports is listed there with
its doc comments, types, and signatures. Start from the top-level ztls module
and drill into ClientHandshake, ServerHandshake, RecordBuffer, Outbox,
signature, and the key-exchange modules.
The Guide is the prose companion: it explains the drive loop and buffer ownership that the type signatures alone don’t convey.
Security Policy
ztls is a TLS 1.3 library. Security is the whole job. Read this before you rely on it for anything.
Status: pre-alpha, unaudited
ztls has not had an external security audit. The API is not stable. It has not been deployed anywhere that matters. Do not put it in front of real traffic or real secrets yet.
That is not false modesty.
PRODUCTION_READINESS.md
tracks exactly what is proven and what isn’t, with the evidence behind each
claim. If you’re weighing
ztls for anything security-sensitive, read that first, then decide.
What ztls does have: RFC-cited tests, RFC 8448 known-answer vectors, OpenSSL
interop in both directions, tlsfuzzer and TLS-Anvil conformance runs, Wycheproof
boundary vectors at the crypto seam, fuzzing on the parser and record-decrypt
surfaces, and a documented threat model at
docs/research/THREAT_MODEL.md.
That is real evidence. It is not a substitute for an audit.
Reporting a vulnerability
Report privately. Do not open a public issue for a security bug.
Use GitHub’s private vulnerability reporting: go to the Security tab of this repository and click Report a vulnerability. That opens a private advisory visible only to the maintainer.
Please include what you’d want if the roles were reversed: affected version or commit, a description of the flaw, and a reproduction — a failing test, a fuzz input, a packet capture, or a proof-of-concept. If you have a suggested fix, even better.
What’s in scope
The TLS 1.3 protocol implementation ztls owns: record framing, the handshake state machine, transcript hashing, key schedule, alert handling, certificate path validation and hostname verification, and the parser surfaces that consume attacker-controlled bytes.
Out of scope:
- The libcrypto backend itself (OpenSSL, AWS-LC). Report primitive crypto bugs upstream. Report misuse of the backend by ztls here.
- Anything the caller owns: transport I/O, buffer lifetime, trust-store
provisioning, the drive loop.
docs/research/THREAT_MODEL.mddraws the line between ztls’s responsibilities and the caller’s. - Features that don’t exist yet (client cert auth, PSK/resumption, 0-RTT). Those are tracked as open issues, not vulnerabilities.
Expectations
This is pre-alpha software maintained in the open. There is no response-time SLA. Reports are read and taken seriously, but fixes land on a best-effort basis until the project reaches a stage where it can promise more. When ztls is ready to make security commitments it can keep, this file will say so.