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
cryptokernel transmit offloadfully managed
copieszero-copy sendbuffered both ways
TLS versions1.3 only1.2 and 1.3
featuresALPNclient certs, resumption, everything
dependencyLinux tls module + OpenSSL 3none - portable
throughputbaseline~0.65× of kTLS (measured)
rolefast pathcompatibility 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:

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:

  1. captures the TLS 1.3 server traffic secret from OpenSSL's keylog callback;
  2. derives the AES-128-GCM key and IV with HKDF-Expand-Label (RFC 8446);
  3. programs them into the socket - setsockopt(TCP_ULP, "tls") then setsockopt(SOL_TLS, TLS_TX, ...) with record sequence 0;
  4. 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:

kTLS: constraints and requirements

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

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.