tls API reference View on GitHub

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

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:

What tls is not for, yet

Being honest about this is the whole brand, so here it is straight:

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

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:

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 moduleImport nameRequirement
tlscaller-chosen, normally tlsZig 0.16
tls_stdcaller-chosen, normally tls_stdZig 0.16
tls_xevcaller-chosen, normally tls_xevZig 0.16 and the dependency option .xev = true
tls_ktlscaller-chosen, normally tls_ktlsZig 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.

AreaSupported pathNotes
TLS versionsTLS 1.3TLS 1.2 and DTLS are out of scope.
Cipher suitesTLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256Suite expansion is provider work.
Key exchangeX25519, P-256, opt-in P-384, and the three opt-in RFC 10024 hybrid groups on both rolesP-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.
AuthenticationServer certificate authentication and client certificate authentication (formerly #4)
ResumptionPSK/session resumption (formerly #2) and 0-RTT with caller-owned anti-replay (formerly #3)
HRRIn-memory and TLS-Anvil; OpenSSL forced-HRR interop is ungated (formerly #1)

Buffer ownership

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
}

The drive loop

Every connection follows the same pattern, whether client or server:

  1. Emit — call an engine method that produces bytes (start, handleRecord, sendApplicationData).
  2. Write — send those bytes to the transport.
  3. Acknowledge — call completeWrite() to tell the engine the bytes were sent.
  4. Read — pull more bytes from the transport into RecordBuffer.
  5. 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:

  1. ServerHandshake.Config carries the ServerHello random up front.
  2. Server credentials are configured before the ClientHello arrives, and sendServerFlightBuffered sends 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:

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:

VariantMeaningWhen it occurs
.write: []const u8A record that must be sent to the peerClient Finished, KeyUpdate response
.application_dataDecrypted plaintext from the peerConnected phase only
.noneNothing to send; state advanced internallyChangeCipherSpec discarded, flight partial
.closedPeer sent close_notifyAny 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 handleRecord for the ClientHello, inspect clientServerName(), select the appropriate keypair/cert, call setCredentials, then send the authenticated flight with sendServerFlightBuffered.

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

PurposePublic type or boundCapacity contract
Client outputClientHandshake.OutBufferHolds one maximum wire record.
Server outputServerHandshake.OutBufferHolds one maximum wire record.
Server flightServerHandshake.FlightBufferHolds one authenticated server flight.
Stream inputRecordBuffer.StorageUses RecordBuffer.recommended_storage.
Minimum stream inputRecordBuffer.MinStorageHolds 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:

Common drive methods:

Policy fields (seeded from Config at init, overridable after):

FieldConfig fieldEffect
policy.host_nameConfig.host_nameExpected DNS name for SAN/CN verification + SNI.
policy.bundleConfig.bundleCaller-owned trust-anchor bundle.
policy.now_secConfig.now_secValidity-period timestamp.
policy.insecure_no_chain_anchorConfig.insecure_no_chain_anchorTest/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:

Common drive methods:

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):

LaneEstablishedSessionServerHandshake
OpenSSL (heap EVP_CIPHER_CTX pointers)736 B19,408 B
AWS-LC 5.5.0 (inline EVP_AEAD_CTX)1,872 B21,120 B
BoringSSL (inline EVP_AEAD_CTX)1,856 B21,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:

Methods (all mirror their ServerHandshake counterparts):

Outbox

RecordBuffer

Signing and key exchange

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:

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.