TLS
ioxide gives you two ways to serve TLS, with a deliberate trade-off between
them. kTLS (ioxide.tls) is the fast path: the handshake runs over the ring
and the kernel encrypts your responses, zero-copy. SslStream over
ConnectionStream is the portable path: fully managed, full-featured, and a bit
slower. Both terminate on a dedicated port and need nothing from
the core engine beyond what is already public.
kTLS (ioxide.tls) | SslStream + ConnectionStream | |
|---|---|---|
| crypto | kernel transmit offload | fully managed |
| copies | zero-copy send | buffered both ways |
| TLS versions | 1.3 only | 1.2 and 1.3 |
| features | ALPN | client certs, resumption, everything |
| dependency | Linux tls module + OpenSSL 3 | none - portable |
| throughput | baseline | ~0.65× of kTLS (measured) |
| role | fast path | compatibility path |
kTLS: the model
Kernel TLS (kTLS) lets the kernel encrypt and decrypt TLS records on an ordinary socket.
The catch is that kTLS only handles the record layer - it does not do the handshake.
So ioxide.tls splits the work:
- Handshake in userspace, over the ring. OpenSSL runs the TLS 1.3 handshake through memory BIOs; the ciphertext flows over the connection's normal recv/send. Handshake bytes ride the same io_uring as everything else, and the handshake resumes inline like any await.
- Transmit offloaded to the kernel. Once the handshake completes, the negotiated keys are programmed into the socket. From then on your handler writes plaintext and the kernel produces the TLS records on the existing io_uring send path - no managed crypto, no extra copy.
- Receive stays in userspace. Inbound records are decrypted by OpenSSL. Requests are small, so this is cheap; the heavy direction (responses) is the one the kernel handles.
kTLS: setup
Open a TLS listener with ExtraPorts and start a
TlsService per reactor from OnStart (same pattern as any client):
var config = new ServerConfig { Port = 8080, ExtraPorts = [8443], ReactorCount = Environment.ProcessorCount };
var options = new TlsOptions
{
CertificatePath = "/certs/server.crt", // PEM chain
KeyPath = "/certs/server.key", // PEM private key
Alpn = "http/1.1",
};
reactor.OnStart = r => TlsService.Start(r, options);
kTLS: the handler
Branch on the listener port. AcceptAsync runs the handshake and the kTLS
hand-off, returning a TlsSession for the receive side. After that, writes are
plaintext (the kernel encrypts) and each inbound slice is decrypted through the session.
reactor.Handle = async (r, conn) =>
{
TlsSession? tls = null;
try
{
if (conn.ListenerPort == 8443)
{
tls = await r.GetService<TlsService>().AcceptAsync(conn);
// The client's first request can arrive bundled with its Finished -
// anything already decrypted during the handshake is here.
Feed(tls.DrainPlaintext());
}
while (true)
{
var snapshot = await conn.ReadAsync();
while (conn.TryGetItem(snapshot, out var item))
{
if (!item.HasBuffer) continue;
// kTLS: decrypt inbound in userspace; raw: item.AsSpan()
Feed(tls != null ? tls.Decrypt(item.Ptr, item.Len) : item.AsSpan());
conn.ReturnBuffer(in item);
}
conn.Write(response); // plaintext in; the kernel makes the records
await conn.FlushAsync(); // ordinary io_uring send
if (snapshot.IsClosed || (tls?.Closed ?? false)) return;
conn.ResetRead();
}
}
finally { tls?.Dispose(); conn.DecRef(); }
};
Two details worth knowing. First, send first. A request can ride in with the
handshake's final flight (returned by DrainPlaintext), so a loop that blocks on a
read before answering it would deadlock - structure the loop to answer buffered input before
parking on the next read. Second, MSG_WAITALL. The reactor normally sets
MSG_WAITALL on sends so the kernel coalesces short writes into one completion -
but kTLS rejects that flag (EOPNOTSUPP). ioxide.tls clears the connection's
SendOpFlags after the hand-off; the reactor's partial-send loop keeps correctness
without it. You do not have to think about either - the handler above is the whole contract.
kTLS: how the hand-off works
After SSL_accept completes over the memory BIOs, AcceptAsync:
- captures the TLS 1.3 server traffic secret from OpenSSL's keylog callback;
- derives the AES-128-GCM key and IV with HKDF-Expand-Label (RFC 8446);
- programs them into the socket -
setsockopt(TCP_ULP, "tls")thensetsockopt(SOL_TLS, TLS_TX, ...)with record sequence 0; - flips the connection to plaintext sends.
From the next write on, the kernel emits the records. Session tickets are disabled
(SSL_CTX_set_num_tickets(0)): a ticket would consume a record sequence number after
the handshake and desync the hand-off, which assumes the first application record is sequence
zero.
kTLS: receive, and the one limitation
The transmit side is kernel-offloaded; the receive side is userspace. Each inbound
slice is fed to OpenSSL and decrypted with SSL_read (that is what
TlsSession.Decrypt does). This is the deliberate boundary of the implementation:
- Why not offload RX too? kTLS RX can only take over at a clean TLS record boundary with the right sequence number. Because the reactor's multishot recv pulls arbitrary byte ranges into provided buffers, a buffer can split a record across the switch - corrupting the stream. Doing it safely needs to quiesce the recv, check alignment, and fall back to userspace when it is not clean. It is a real feature, not a small one.
- Does it matter? Rarely. The cost of userspace RX is the decryption of the request, which is tiny for an HTTP API - measured around twice the per-byte cost of plaintext, but only on the inbound bytes. It is invisible on response-heavy workloads and only bites large uploads.
kTLS: constraints and requirements
- TLS 1.3 only, single cipher suite
TLS_AES_128_GCM_SHA256- the kTLS key layout we program requires a fixed, known cipher. - No session resumption (tickets disabled, see above).
- ALPN is selected from the client's offer (default
http/1.1). - Linux
tlskernel module (modprobe tls; standard on mainstream distros) and OpenSSL 3 (present in the .NET runtime images).
kTLS: performance
On a plaintext-equivalent workload kTLS runs at roughly two thirds of plaintext throughput. The cost is dominated by the inherent work of encrypting each response - which the kernel does, on the existing send path, with no userspace copy - plus the small fixed cost of decrypting each request. There is no managed-crypto tax and no extra copy on the send side, which is exactly why it beats a userspace TLS stack: those copy plaintext into a TLS library before encrypting; kTLS skips that copy entirely. The remaining gap to plaintext is the AES-GCM work itself, which no TLS implementation avoids.
SslStream over ConnectionStream
When you want TLS without the kernel module, or you need TLS 1.2 clients, client
certificates, or session resumption, use the BCL's SslStream over a
ConnectionStream. ConnectionStream is a general-purpose
Stream over a connection (in the core engine), so this path needs nothing from
ioxide.tls at all:
reactor.Handle = async (r, conn) =>
{
var ssl = new SslStream(new ConnectionStream(conn), leaveInnerStreamOpen: false);
await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
{
ServerCertificate = cert,
ApplicationProtocols = [SslApplicationProtocol.Http11],
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
});
var buffer = new byte[8192];
while (true)
{
int n = await ssl.ReadAsync(buffer); // SslStream decrypts
if (n == 0) break;
await ssl.WriteAsync(response); // SslStream encrypts
}
ssl.Dispose();
conn.DecRef();
};
It stays on the reactor. ConnectionStream is built on the same
RunContinuationsAsynchronously = false value-task sources as the rest of the
engine, and SslStream uses ConfigureAwait(false) and does its crypto
inline - so for request/response traffic the whole thing resumes inline on the reactor thread,
handshake included. (Measured: zero thread-pool hops across millions of requests.) The one case
that would hop is genuinely concurrent read+write on a single SslStream - a
full-duplex pattern like WebSockets - where its internal lock parks one side on the pool.
Plain HTTP never does that.
The trade-off is throughput. SslStream buffers and copies on both sides
and does all crypto in managed code, so it runs around 0.65× the throughput of kTLS on
the same workload. In return you get TLS 1.2 and 1.3, client certificates, resumption, and zero
native dependencies - portable to any OS. The ConnectionStream bridge itself is
allocation-free and is not the bottleneck; the cost is SslStream's own.
Which to use
- Maximum TLS throughput on Linux 6+ with the
tlsmodule available, and TLS 1.3 / ALPN is enough → kTLS (ioxide.tls). - Portability, older clients, or full TLS features (client certs, resumption,
TLS 1.2), or you just do not want a kernel-module dependency → SslStream over
ConnectionStream.
Both ride a dedicated port via multi-port, so you can even serve plaintext on one port and either TLS path on another from the same reactors.