A TLS 1.3 implementation 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. tls does the protocol.
It is pre-alpha. The API will change.
- Why tls: when to reach for it, and when to use something mature instead.
- Guide: buffer ownership, the drive loop, and working client and server code.
- API reference: every public declaration.
- Security: status and how to report a vulnerability.
Why tls
The README tells you what tls 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: tls 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. tls takes it further on memory: no allocator in the engine at all.
What tls is for
Reach for tls when:
- You own your I/O and want to keep owning it. epoll, io_uring, a green-thread scheduler, an in-memory test harness — tls 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 tls 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. tls 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. tls intentionally supports a bounded TLS 1.3
surface rather than every legacy or optional feature. See
PRODUCTION_READINESS.mdfor the canonical supported surface and remaining gaps. - 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 tls will tell you when that changes.
The performance argument
Performance is the reason tls is being built rather than the reason to adopt it today. The claim is narrow and evidence-backed: on in-memory application-data benchmarks, tls 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 tls 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.
Guide
tls is a pure TLS 1.3 state machine. The caller supplies bytes, buffers, transport I/O, and the drive loop. tls protocol code allocates no memory. Production crypto backends can allocate memory inside provider operations.
Mental model
Network bytes (TLS records)
| ^
feed | | drain
v |
+-----+---------+-----+
| |
| Engine (tls) |
| 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— tls client plus tls 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.integrations/tls-ktls/examples/ktls_pingpong.zig— Zig 0.16/Linux userspace handshake followed by kernel TX/RX, live KeyUpdate, and explicit close alerts.
just examples-ci builds and runs the core paths. The Zig 0.16 integration gate runs the packaged examples and tests. If a drive-loop shape here diverges from those examples, this document is the stale side.
Fresh project setup
Use Zig 0.16. tls 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-tls
cd hello-tls
zig init
Fetch tls from main; Zig records a content hash even though the branch moves:
zig fetch --save https://github.com/baskingspot/tls/archive/main.tar.gz
Keep the fingerprint generated by zig init. For a sibling checkout instead,
use a path relative to the consumer project root:
.dependencies = .{
.tls = .{ .path = "../tls" },
},
Wire the core dependency module into the executable in build.zig:
const tls_dep = b.dependency("tls", .{
.target = target,
.optimize = optimize,
});
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{.{ .name = "tls", .module = tls_dep.module("tls") }},
});
const exe = b.addExecutable(.{ .name = "hello-tls", .root_module = exe_mod });
Then import tls from application code:
const tls = @import("tls");
The root package also exposes all three integration modules from the same fetch:
| Dependency module | Import name | Requirement |
|---|---|---|
tls | caller-chosen, normally tls | Zig 0.16 |
tls_std | caller-chosen, normally tls_std | Zig 0.16 |
tls_xev | caller-chosen, normally tls_xev | Zig 0.16 and the dependency option .xev = true |
tls_ktls | caller-chosen, normally tls_ktls | Zig 0.16 on Linux |
For example, a tls-std consumer replaces the import entry above with:
.imports = &.{.{
.name = "tls_std",
.module = tls_dep.module("tls_std"),
}},
A libxev consumer opts in when resolving the dependency so core-only consumers do not fetch libxev:
const tls_dep = b.dependency("tls", .{
.target = target,
.optimize = optimize,
.xev = true,
});
const tls_xev = tls_dep.module("tls_xev");
All imported modules use the same libcrypto headers and library. Select them
through PKG_CONFIG_PATH. The compiler infers OpenSSL, AWS-LC, or BoringSSL
from the selected headers. Add .@"crypto-fips" = true only to narrow OpenSSL
or AWS-LC capabilities for an explicitly configured FIPS build.
Importing tls_xev without .xev = true reports the missing opt-in. The three
integration directories keep standalone manifests for in-tree development, but
external consumers should depend only on the root package. For a commit URL,
use zig fetch --save git+https://github.com/baskingspot/tls#<commit> and keep
the generated hash; do not hand-write dependency hashes.
Supported surface for adopters
tls is TLS 1.3 only. The supported user-facing path is server-authenticated 1-RTT over caller-owned buffers.
| Area | Supported path | Notes |
|---|---|---|
| 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, P-256, opt-in P-384, and the three opt-in RFC 10024 hybrid groups on both roles | P-384 and the hybrids have bidirectional OpenSSL 3.6 interop on every non-FIPS backend. The hybrids also pass the pinned upstream tlsfuzzer matrix. FIPS backend identities reject hybrid configuration. |
| Authentication | Server certificate authentication and client certificate authentication (formerly #4) | |
| Resumption | PSK/session resumption (formerly #2) and 0-RTT with caller-owned anti-replay (formerly #3) | |
| HRR | In-memory and TLS-Anvil; OpenSSL forced-HRR interop is ungated (formerly #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: [tls.RecordBuffer.recommended_storage]u8 = undefined;
var rb: tls.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: tls.ClientHandshake.OutBuffer = .empty;
var storage: tls.RecordBuffer.Storage = .empty;
var rb: tls.RecordBuffer = .init(&storage.buffer);
var random: tls.Random = undefined;
std.crypto.random.bytes(&random.data);
var hs: tls.ClientHandshake = .init(.{
.keypairs = try .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: tls.Random = undefined;
std.crypto.random.bytes(&random.data);
var hs: tls.ServerHandshake = .init(.{
.keypairs = try .init(server_keypair),
.random = random,
.alpn_protocols = &.{"h2"},
});
var signer: tls.signature.PrivateKey = try .fromP256Scalar(scalar[0..32]);
defer signer.deinit();
hs.setCredentials(&.{cert_der}, signer.signer());
var out: tls.ServerHandshake.OutBuffer = .empty;
var flight: tls.ServerHandshake.FlightBuffer = .empty;
var storage: tls.RecordBuffer.Storage = .empty;
var rb: tls.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,
}
if (hs.isConnected()) break;
}
}
Break out as soon as client Finished makes the server connected. Any later
record already in rb remains buffered; this gives post-handshake work such as
a NewSessionTicket write priority over coalesced application data.
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 tls.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: tls.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 tls.
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.
The Zig 0.16/Linux integrations/tls-ktls package owns the safe activation and data-plane lifecycle. It requires an empty RecordBuffer, no pending engine write, no unanswered KeyUpdate request, and an application-level barrier preventing the peer from racing a new record into the socket during activation; keeps key snapshots internal to immediate setsockopt calls; receives control-record metadata with recvmsg; processes kernel-decrypted records through receiveKtlsRecord; ratchets external TX records through ratchetKtlsTx; and sends close_notify explicitly. Use that package instead of open-coding the sequence from the raw getters.
The raw exports remain available for specialized integrations. They are immediate-use snapshots: retaining one across any record operation, or reinstalling stale sequence/key material, can cause epoch desynchronization or nonce reuse.
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: tls.signature.PrivateKey = try .fromP256Scalar(scalar[0..32]);
defer signer.deinit();
// RFC 6979 deterministic nonces (mattrobenolt/tls#82): reproducible
// CertificateVerify bytes for seeded test harnesses; see "Signing and key
// exchange" for the capability contract.
// signer.nonce_mode = .deterministic;
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: tls.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. On PSK resumption these methods automatically omit Certificate and CertificateVerify. PrivateKey.deinit() zeroes key material via libcrypto.
Server session tickets
After verifying client Finished, a server can issue one or more resumable TLS 1.3 tickets without allocating. Ticket identity encoding, persistence, expiration, key rotation, replay policy, absolute resumption-chain lifetime, and any client-auth identity continuity remain caller policy.
var prepared = try server.deriveTicketPsk();
defer prepared.secureZero();
// Stage prepared.psk and prepared.cipher_suite under this opaque identity, or
// include them in a caller-encrypted and authenticated stateless ticket.
const identity = "opaque-ticket";
// `fresh_ticket_age_add` came from the application's CSPRNG for this ticket.
const record = try server.sendNewSessionTicket(
&prepared,
.{
.ticket_lifetime = 3600,
.ticket_age_add = fresh_ticket_age_add,
.ticket = identity,
},
&out.buffer,
);
try stream.writeAll(record);
server.completeWrite();
deriveTicketPsk() assigns a unique per-connection nonce and permits only one
prepared ticket at a time. RFC 8446 §4.6.1 puts ticket_age_add freshness on
the server; tls owns no CSPRNG, so the caller must provide a fresh
cryptographically random value for every call. Ticket identities are non-empty
and at most 256 bytes so tls clients can cache them. The server emits one ticket per
record, caps issuance at 32 tickets per connection, and does not advertise
0-RTT from this API.
Clients that did not advertise psk_dhe_ke make ticket preparation fail with
error.IncompatiblePskModes. Commit a stateful ticket row only after the record
has been fully written. If identity construction or persistence fails, call
discardTicketPsk(); it erases the PSK and burns the nonce.
Issue and flush every userspace ticket before kTLS activation. A prepared ticket
blocks tls_ktls.Server.activate; after successful activation,
deriveTicketPsk() and sendNewSessionTicket() fail with
error.KtlsTxActive.
PSK resumption never implicitly preserves a client-certificate identity. A server configured for fresh client authentication declines offered PSKs and runs a full authenticated handshake. Applications that intentionally carry an identity in a ticket must authenticate and authorize that caller-owned ticket state themselves.
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: tls.ClientHandshake = .init(.{
.keypairs = try .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).
RFC 10024 hybrid key exchange
Hybrid groups are opt-in. The non-FIPS OpenSSL, AWS-LC, and BoringSSL backends support all three RFC 10024 groups. The provider supplies pure ML-KEM operations. tls owns group negotiation and classical-secret composition. FIPS backend identities advertise no hybrid groups.
tls.capabilities.active_backend identifies the inferred backend and explicit FIPS policy. tls.capabilities.is_fips reports compile-time FIPS narrowing. It does not prove the provider runtime mode. supportsHybridGroup(role, group) reports role support. Provider operations can still fail at runtime.
Start with one group:
const hybrid_groups = [_]tls.kex.NamedGroup{.x25519_mlkem768};
try tls.capabilities.validateHybridGroups(.client, &hybrid_groups);
try tls.capabilities.validateHybridGroups(.server, &hybrid_groups);
var client_keypairs: tls.ClientHandshake.KeyPairs = try .init(.generate());
defer client_keypairs.secureZero();
const client_config: tls.ClientHandshake.Config = .{
.keypairs = client_keypairs,
.host_name = "example.com",
.now_sec = std.time.timestamp(),
.random = client_random,
.hybrid = .{
.supported_groups = &hybrid_groups,
.initial_key_share = .x25519_mlkem768,
},
};
try client_config.validate();
var client: tls.ClientHandshake = .init(client_config);
defer client.deinit();
var server_keypairs: tls.ServerHandshake.KeyPairs = try .init(.generate());
defer server_keypairs.secureZero();
const server_config: tls.ServerHandshake.Config = .{
.keypairs = server_keypairs,
.random = server_random,
.hybrid_groups = &hybrid_groups,
};
try server_config.validate();
var server: tls.ServerHandshake = .init(server_config);
defer server.deinit();
Set initial_key_share = null to omit the hybrid share from ClientHello1. A compatible server can select the group through HelloRetryRequest.
A hybrid offer still permits classical key exchange. Invalid explicit policy never causes a silent classical fallback. A failure after hybrid selection ends the handshake.
error.InvalidHybridPolicy reports malformed lists or an inconsistent initial share. error.HybridGroupUnavailable reports compile-time backend policy. error.MissingP384KeyPair reports absent P-384 material.
secp384r1_mlkem1024 requires a P-384 keypair on each enabled role. All three group values can appear in one policy.
The group slices are borrowed. They must outlive the handshake. A reusable xev configuration requires them until every associated connection ends.
Call tls-std option validate() methods before socket creation. connect and accept repeat validation before key generation. The fallible xev configuration constructors validate before trust-store work.
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: tls.ClientHandshake = .init(.{
.keypairs = try .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.
Peer alert diagnostics
When the peer sends a non-close_notify alert — a fatal error alert, or a warning-level user_canceled — handleRecord returns error.PeerAlert (RFC 8446 §6.2). The engine retains the exact level and description so you can log it:
_ = engine.handleRecord(record, &out) catch |err| switch (err) {
error.PeerAlert => {
if (engine.lastPeerAlert()) |peer_alert| {
// Level/Description are non-exhaustive: unknown codes are legal
// on the wire, so tagName can return null. Fall back to the code.
const level_name = std.enums.tagName(tls.alert.Level, peer_alert.level)
orelse "unknown";
const desc_name = std.enums.tagName(tls.alert.Description, peer_alert.description)
orelse "unknown";
// e.g. "peer alert: fatal(2)/handshake_failure(40)"
std.log.err("peer alert: {s}({d})/{s}({d})", .{
level_name,
@intFromEnum(peer_alert.level),
desc_name,
@intFromEnum(peer_alert.description),
});
}
return err; // the connection is dead
},
else => return err,
};
lastPeerAlert() returns null until the first peer alert; a later peer alert replaces an earlier one, while close_notify (§6.1) and malformed records leave it unchanged, and reinitialization clears it. Do not use @tagName directly on these values — unknown codes would panic. The engine still returns error.PeerAlert; only the detail is new.
Buffer sizing
| Purpose | Public type or bound | Capacity contract |
|---|---|---|
| Client output | ClientHandshake.OutBuffer | Holds one maximum wire record. |
| Server output | ServerHandshake.OutBuffer | Holds one maximum wire record. |
| Server flight | ServerHandshake.FlightBuffer | Holds one authenticated server flight. |
| Stream input | RecordBuffer.Storage | Uses RecordBuffer.recommended_storage. |
| Minimum stream input | RecordBuffer.MinStorage | Holds one maximum wire record. |
Use the public buffer types unless the application needs custom storage. Smaller custom slices can return error.BufferTooShort.
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.Configrequireskeypairs,host_name,now_sec, andrandom. Other fields have defaults.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.lastPeerAlert()— the most recent non-close_notify peer alert (RFC 8446 §6.2), ornull; see Peer alert diagnostics.
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.lastPeerAlert()— the most recent non-close_notify peer alert (RFC 8446 §6.2), ornull; see Peer alert diagnostics.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.extractEstablished()— atisConnected(), exactly once, hand the connection's post-handshake life to a compactEstablishedSession(736 bytes on the OpenSSL lane vs the handshake engine's 19,408; see the lane table below) and consume this engine. See below.
Low-level in-memory hooks used by examples/in_memory_handshake.zig: acceptClientHello, processClientFinished, and receiveApplicationData.
EstablishedSession
The compact post-handshake server engine, constructed only by
ServerHandshake.extractEstablished() at handshake completion (#115). A
connection pool keeps one ServerHandshake per worker and one
tls.EstablishedSession per live connection. The size is lane-dependent
because each RecordLayer embeds its backend AEAD context (measured under
Zig 0.15.2, aarch64-linux; x86_64 compiles to the same numbers; tls now
requires Zig 0.16, so treat these as historical, not current):
| Lane | EstablishedSession | ServerHandshake |
|---|---|---|
OpenSSL (heap EVP_CIPHER_CTX pointers) | 736 B | 19,408 B |
AWS-LC 5.5.0 (inline EVP_AEAD_CTX) | 1,872 B | 21,120 B |
BoringSSL (inline EVP_AEAD_CTX) | 1,856 B | 21,088 B |
It preserves the traffic record layers and sequences, the application traffic secrets, an in-flight KeyUpdate fragment, the consecutive-KeyUpdate counter, the last peer alert, an owed KeyUpdate response, and the pending-write latch. It runs the same connected record-path code the handshake engine runs, so classification, KeyUpdate ratchet/response discipline, the flood cap, and the latch behave identically.
Lifecycle rules:
- Call
extractEstablished()exactly once, whileisConnected(). The handshake engine becomes unusable (itsStatereports.extracted), and the record-layer bytes it left behind — which on AWS-LC and BoringSSL are the live traffic keys — are wiped immediately at extraction, without freeing the moved contexts.deinitremains safe to call immediately afterwards: it wipes the engine's remaining duplicated secret bytes and never touches the moved record-layer contexts. - Issue NewSessionTickets (and discard any prepared one) BEFORE extracting. The established session carries no ticket machinery.
- Both record directions must be userspace when extracting. TX ownership is
tracked and asserted; RX ownership is not, so it is a caller contract — a
kernel-installed RX leaves the session's userspace RX sequence stale, and
misuse fails loudly (
error.AuthenticationFailed). kTLS callers keep usingServerHandshakein both directions.
Methods (all mirror their ServerHandshake counterparts):
deinit()— release backend contexts and zero the secrets the session owns.handle(record, out)— decrypt, classify, and auto-respond to an inboundupdate_requestedKeyUpdate with the response record inout.receive(record)— RX-only classify; a receivedupdate_requesteddefers the response to the caller (query withhasPendingKeyUpdateResponse()).sendApplicationData(plaintext, out)— refused witherror.PendingKeyUpdateResponsewhile a response is owed, and witherror.PendingWriteuntil the previous record is acknowledged.sendAlert(description, out)/sendKeyUpdate(request, out)/ratchetTx(request)— post-handshake emits;ratchetTxadvances the TX epoch after an external record layer sent a KeyUpdate under the old key.hasPendingWrite()/completeWrite()/hasPendingKeyUpdateResponse()/lastPeerAlert()— the same interlocks and diagnostics as the handshake engine.
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
CertificateChain.fromPem(pem, der_storage, chain_storage)decodes RFC 7468 certificate blocks into caller-owned storage. The output buffers must not overlappemand must outlive the chain.signature.PrivateKey.fromPem(scheme, pem)andfromDer(scheme, der)load private keys from caller-owned bytes. The scheme is caller-pinned metadata and is not validated against the key at load time.signature.PrivateKey.fromPemAuto(pem)(#112) loads the key and infers the CertificateVerify scheme from the key's type and curve (RSA →rsa_pss_rsae_sha256, P-256 →ecdsa_secp256r1_sha256, P-384 →ecdsa_secp384r1_sha384). The inferred scheme is gated against the active backend's advertised CertificateVerify schemes, so a key whose scheme this backend cannot sign (Ed25519, P-521 today) fails at load witherror.UnsupportedKeySchemeinstead of at the first handshake signature. The RFC 8446 §4.2.3rsa_pss_pss_*schemes are not implemented; a backend that loads an rsassaPss-OID key rejects it asUnsupportedKeyScheme, while a backend that cannot load the container returnsLibcryptoFailed. Success means the linked backend advertises the inferred scheme; callers still handle signing failures from malformed or unsuitable key material. A PEM that never loads fails witherror.LibcryptoFailed.- Encrypted private keys are not supported (#114). Both PEM loaders pass an explicit provider callback that declines passwords, so an encrypted key — PKCS#8
ENCRYPTED PRIVATE KEYor the legacyProc-Type: 4,ENCRYPTEDcontainer — fails witherror.LibcryptoFailedinstead of prompting on the controlling terminal or reading process stdin. Loading is therefore safe on an unattended startup path, and there is no caller-owned password API to pass a passphrase through; decrypt the key with external tooling first, or load plaintext PEM/DER. signature.PrivateKey.pairsWith(leaf_cert_der)(#113) validates at load time that the private key matches the leaf certificate's public key (RFC 8446 §4.4.3; the leaf is element [0] of the chain).error.CertificateKeyMismatchmeans the leaf parsed but the keys do not pair;error.InvalidCertificatemeans the input is not exactly one DER-encoded X.509 certificate — distinct errors so rotation tooling can tell a wrong pairing from a corrupt file. Both inputs are borrowed; the private key is only read. APrivateKeymust outlive everySignerit handed out — swap credentials only when no in-flight handshake holds the old signer.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.signature.PrivateKey.nonce_modeselects the ECDSA nonce strategy (signature.NonceMode): the default.randomis the production path;.deterministicopts into RFC 6979 deterministic nonces so identical key and message produce identical signature bytes, which makes a seeded handshake transcript byte-reproducible. It requires a backend compiled with nonce-type provider support and an ECDSA scheme; unsupported requests fail loudly —error.DeterministicNonceUnsupportedfor a missing capability or non-ECDSA scheme,error.LibcryptoFailedwhen the provider rejects the parameter — instead of silently signing with a random nonce. Keep.randomin production.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 with OS entropy.x25519.KeyPair.fromSecret(secret_key)derives a keypair from a caller-supplied 32-byte scalar. Production callers must supply fresh CSPRNG bytes for each handshake. The function supports batched entropy draws.x25519.KeyPair.generateDeterministic(seed)remains available for tests and fixed-vector paths.x25519.sharedSecret(secret_key, peer_public_key, out)writes the shared secret to caller-owned storage. The function clears the output on error.p256.KeyPair.fromSecret(secret_key)derives a keypair from a caller-supplied 32-byte scalar. Production callers must supply fresh CSPRNG bytes for each handshake.error.IdentityElementfromp256.KeyPair.fromSecretrequires a fresh draw.error.LibcryptoFailedis terminal.ClientHandshake.KeyPairs.init(x25519_keypair)generates the P-256 half and can returnp256.Error. The caller sheds the handshake after failure (#88).initWithP256andinitWithP256P384accept fixed caller-supplied keypairs.
Secret cleanup
After use, call secureZero() on each caller-owned X25519, P-256, P-384, or aggregate handshake keypair.
Before release of resumption material, call SessionTicket.secureZero().
After kernel setup, call KtlsInfo.secureZero().
After kernel setup, call secureZero() on the packed kTLS value.
After use, call handshake deinit() methods to release provider handles and erase each handshake-owned secret copy. EstablishedSession.deinit() does the same for the extracted post-handshake session; the consumed handshake engine's own deinit() stays safe after extraction.
Do not erase borrowed buffers, Signer values, or borrowed PSK slices through these owners.
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 tls client and tls 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 tls.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
tls covers PSK/session resumption, 0-RTT, client certificate authentication, and HRR in the supported surface. These features are intentionally out of scope for the examples above:
- Exporters — not implemented; not on the timeline until a feature request earns it.
- A general io_uring or arbitrary-runtime wrapper. In-tree integration packages
cover
std.Io, libxev, and Linux kTLS without moving transport I/O into the core engine.
Security
tls is pre-alpha, and no external security audit has happened. Do not put it in front of real traffic or real secrets yet. Three findings from an internal adversarial review are fixed, each with a regression test.
To report a vulnerability, use GitHub's private advisory form. The full policy, including scope and what counts, is SECURITY.md in the repository.