TLS internals

ioxide.tls terminates TLS 1.3 with a deliberate split: the handshake and inbound records go through OpenSSL in userspace, while outbound bulk is offloaded to the kernel (kTLS) so responses are encrypted inline on the io_uring send - zero-copy, no managed crypto. Requests are small and responses big, so the kernel does the heavy direction.

TlsService · TlsService.cs

Per-reactor TLS termination, holding one SSL_CTX shared across that reactor's connections (created in OnStart). The context is TLS 1.3 only, one suite (TLS_AES_128_GCM_SHA256 - kTLS needs a known key layout), with session tickets disabled (they would advance the server's record sequence after the handshake and break the kTLS handoff, which programs sequence 0).

The handshake

AcceptAsync drives a standard memory-BIO server handshake as an ordinary reactor await: SSL_accept → read its error immediately → drain the write BIO into the connection's write slab and FlushAsync → on WANT_READ, recv more ciphertext and feed the read BIO - loop until done. Handshake bytes ride the same ring recv/send as application data, so there's no separate I/O path.

TlsService.cs · the handshake as an ordinary reactor await loop

while (true)
{
    int ret = OpenSsl.SSL_accept(ssl);
    int err = ret == 1 ? 0 : OpenSsl.SSL_get_error(ssl, ret);   // read the error immediately

    await FlushOutbound(conn, wbio);    // drain server flights into the slab, then io_uring send

    if (ret == 1) break;
    if (err != OpenSsl.SSL_ERROR_WANT_READ)
        throw new IOException($"TLS handshake failed: {OpenSsl.LastError()}");

    RecvSnapshot snapshot = await conn.ReadAsync();   // WANT_READ: pull more ciphertext
    bool fed = FeedInbound(conn, rbio, snapshot);
    conn.ResetRead();

    if (snapshot.IsClosed && !fed)
        throw new IOException("connection closed during TLS handshake");
}

Per-session secret via ex_data (no global map)

kTLS keys are derived from the TLS 1.3 server traffic secret, which OpenSSL surfaces through a keylog callback. The callback is a static function pointer with no instance, so to find the right session it reads a GCHandle to the TlsSession stored on the SSL via SSL_set_ex_data / SSL_get_ex_data (the index allocated once with CRYPTO_get_ex_new_index). The callback parses SERVER_TRAFFIC_SECRET_0 and stores it on that session directly - no process-global, recycled-pointer-keyed map, so two reactors that get the same recycled SSL address can't collide. The handle is freed on TlsSession.Dispose.

TlsService.cs · one ex_data index, allocated once; each SSL* carries a GCHandle to its session

private static readonly int SslSessionIndex =
    OpenSsl.CRYPTO_get_ex_new_index(OpenSsl.CRYPTO_EX_INDEX_SSL, 0, 0, 0, 0, 0);

// In AcceptAsync, bind this SSL* to its managed session:
GCHandle handle = GCHandle.Alloc(session);
OpenSsl.SSL_set_ex_data(ssl, SslSessionIndex, GCHandle.ToIntPtr(handle));
session.AttachHandle(handle);

TlsService.cs · the static keylog callback resolves its session through ex_data - no global map

[UnmanagedCallersOnly]
private static void KeylogCallback(nint ssl, nint line)
{
    string? text = Marshal.PtrToStringUTF8(line);
    if (text == null || !text.StartsWith("SERVER_TRAFFIC_SECRET_0 ", StringComparison.Ordinal))
        return;

    nint data = OpenSsl.SSL_get_ex_data(ssl, SslSessionIndex);
    if (data == 0 || GCHandle.FromIntPtr(data).Target is not TlsSession session)
        return;

    int lastSpace = text.LastIndexOf(' ');
    if (lastSpace > 0)
    {
        session.ServerSecret = Convert.FromHexString(text.AsSpan(lastSpace + 1).TrimEnd());
    }
}

ALPN

The configured TlsOptions.Alpn is honored: the wire bytes are passed to the (static) select callback via its arg (a GCHandle), so the protocol isn't hard-coded. The callback finds it in the client's offer list and points *out into the client buffer.

The kTLS handoff

Once the handshake is done and all server flights are flushed, Ktls.EnableTx programs the TX keys into the socket and conn.SendOpFlags = 0 clears MSG_WAITALL (kTLS rejects it; the reactor's partial-send loop preserves correctness). From the next write on, the handler writes plaintext through conn.Write / FlushAsync and the kernel produces the records on the io_uring send path. Faults are surfaced (a missing kernel tls module shows up as a logged handshake failure rather than a silent drop).

TlsService.cs · the handoff - program TX keys, then drop MSG_WAITALL

Ktls.EnableTx(conn.ClientFd, secret);
conn.SendOpFlags = 0;                    // clear MSG_WAITALL - kTLS rejects it (EOPNOTSUPP)
session.MarkTxEnabled(conn.ClientFd);
// from here the handler writes plaintext; the kernel makes the records on the io_uring send

TlsSession · TlsSession.cs

The receive half after the handoff (one per connection, reactor-thread only). Inbound bytes are still TLS records, decrypted in userspace: Decrypt(ciphertext, length) feeds the read BIO and drains SSL_read into a reusable plaintext buffer, returning the span (valid until the next call). The buffer is bounded per-decrypt (a defensive cap; in practice one decrypt processes one recv-buffer-sized slice and decryption never expands). SSL_ERROR_ZERO_RETURN sets Closed (the peer's close_notify). On a clean server-side teardown, Dispose sends a close_notify alert over kTLS (best-effort) so the peer can tell end-of-stream from a truncation, then SSL_frees (which frees both BIOs) and releases the ex_data handle.

Ktls · Ktls.cs

The kernel-TLS transmit offload. EnableTx derives the record key (16B) and IV (12B) from the server traffic secret with RFC 8446 HKDF-Expand-Label (the 12-byte TLS 1.3 nonce is split into the kernel's salt[4] + iv[8]; the record sequence stays 0, valid because tickets are off), then setsockopt(SOL_TCP, TCP_ULP, "tls") attaches the ULP and setsockopt(SOL_TLS, TLS_TX, &cryptoInfo) installs the keys. All key material - the derived key/iv, the crypto-info struct, and the captured secret - is zeroed in a finally with CryptographicOperations.ZeroMemory once programmed. SendCloseNotify sends a {warning, close_notify} alert as a control message (sendmsg with a SOL_TLS / TLS_SET_RECORD_TYPE = alert cmsg), best-effort.

Ktls.cs · derive the AES-128-GCM key/iv, then attach the ULP and install the TX keys

public static unsafe void EnableTx(int fd, byte[] serverTrafficSecret)
{
    byte[] key   = ExpandLabel(serverTrafficSecret, "key", 16);   // RFC 8446 HKDF-Expand-Label
    byte[] nonce = ExpandLabel(serverTrafficSecret, "iv", 12);

    var info = new CryptoInfoAesGcm128
    {
        Version = TLS_1_3_VERSION,
        CipherType = TLS_CIPHER_AES_GCM_128,
    };
    try
    {
        for (int i = 0; i < 16; i++) info.Key[i]  = key[i];
        for (int i = 0; i < 4;  i++) info.Salt[i] = nonce[i];      // 12-byte nonce → salt[4] + iv[8]
        for (int i = 0; i < 8;  i++) info.Iv[i]   = nonce[4 + i];

        ReadOnlySpan<byte> ulp = "tls"u8;
        fixed (byte* p = ulp)
        {
            if (setsockopt(fd, SOL_TCP, TCP_ULP, p, 3) != 0)
                throw new IOException($"kTLS: TCP_ULP failed (errno {Marshal.GetLastPInvokeError()})");
        }
        if (setsockopt(fd, SOL_TLS, TLS_TX, &info, (uint)sizeof(CryptoInfoAesGcm128)) != 0)
            throw new IOException($"kTLS: TLS_TX failed (errno {Marshal.GetLastPInvokeError()})");
    }
    finally
    {
        CryptographicOperations.ZeroMemory(key);                  // wipe key material once programmed
        CryptographicOperations.ZeroMemory(nonce);
        CryptographicOperations.ZeroMemory(serverTrafficSecret);
    }
}

OpenSsl · OpenSsl.cs

The minimal OpenSSL 3 P/Invoke surface (via [LibraryImport] source-gen): context setup (SSL_CTX_new, protocol/ciphersuite/ticket controls, cert/key file loads, the keylog and ALPN callback setters), the per-connection objects (SSL_new/_free, SSL_set_bio/_accept_state/_accept/_read/_get_error), the ex_data accessors, and the memory BIOs (BIO_new/_s_mem/_read/_write/_ctrl_pending). LastError formats the first queued error and drains the rest so the next report isn't stale.

TlsOptions · TlsOptions.cs

The PEM certificate-chain path, the private-key path, and the ALPN protocol to advertise.

An alternative, fully-managed path exists with no kTLS or native dependency: a BCL SslStream over ConnectionStream (everything encrypted in userspace, ~0.65x kTLS throughput) - see the tls-sslstream example.