TLS internals
ioxide.tls terminates TLS with OpenSSL in both directions by
default: the handshake runs over memory BIOs, inbound records decrypt in userspace, and
responses are encrypted before they reach the write slab. Kernel TLS is a per-direction
opt-in (TlsOptions.KernelTx / KernelRx) for the deployments where
sendfile or NIC offload pays for its constraints.
TlsService · TlsService.cs
Per-reactor TLS termination, holding one SSL_CTX shared across that reactor's
connections (created in OnStart). By default the context keeps OpenSSL's own
posture - TLS 1.2 and 1.3, any ciphersuite, session tickets on. Only when
KernelTx opts into kTLS is it pinned to TLS 1.3 with
TLS_AES_128_GCM_SHA256 and tickets disabled: the kernel needs a known key layout,
and a ticket would advance the record sequence after the handshake and break the handoff.
Asking for KernelRx without KernelTx is refused at
Start - RX shares the ULP the TX handoff installs.
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 traffic secrets (server for TX, client for RX), 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), and stores the secret on that session directly - no
process-global, recycled-pointer-keyed map, so two reactors that get the same recycled
SSL address can't collide. On every path that does not program the kernel the
secrets are zeroed at the handoff decision, and again on TlsSession.Dispose,
which also frees the handle.
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)
return;
bool server = text.StartsWith("SERVER_TRAFFIC_SECRET_0 ", StringComparison.Ordinal);
bool client = text.StartsWith("CLIENT_TRAFFIC_SECRET_0 ", StringComparison.Ordinal);
if (!server && !client)
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 (opt-in)
With KernelTx on, 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 kernel produces
the records - which is what makes the kTLS samples' bare conn.Write legal there
and nowhere else. TlsSession.Write is correct in either mode, which is why every
other sample goes through it. With KernelRx also on, the RX keys are programmed
at the same moment, with the record sequence advanced past whatever the handshake already
consumed - and a connection whose handshake left a partial record behind silently keeps the
userspace reader, because those bytes are gone for the kernel.
TlsService.cs · the handoff - program TX keys, then drop MSG_WAITALL
if (_kernelTx)
{
Ktls.EnableTx(conn.ClientFd, secret);
conn.SendOpFlags = 0; // clear MSG_WAITALL - kTLS rejects it (EOPNOTSUPP)
session.MarkTxEnabled(conn.ClientFd);
}
TlsSession · TlsSession.cs
The per-connection TLS state after the handshake (reactor-thread only).
Decrypt(ciphertext, length) feeds the read BIO and drains SSL_read
into a reusable plaintext buffer, returning the span (valid until the next call); under kTLS RX
it is a pass-through, since recv already delivered plaintext. Write is the
mode-aware outbound: plaintext straight to the slab under kTLS TX, encrypted through the write
BIO otherwise. SSL_ERROR_ZERO_RETURN sets Closed (the peer's
close_notify). On a clean server-side teardown, Dispose sends close_notify in
whichever mode applies - a kTLS control send, or SSL_shutdown's record delivered
with a best-effort raw send - zeroes any remaining secrets, 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.
EnableRx is the same dance for the receive keys, taking the record sequence the
handshake already consumed (the sequence is part of the AEAD nonce, so it cannot be guessed).
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/_write/_shutdown/_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, the ALPN protocols to advertise
(most preferred first), and the two kernel-offload opt-ins: KernelTx (kTLS
transmit - constrains the context to TLS 1.3, one suite, no tickets) and KernelRx
(experimental kernel receive; requires KernelTx).
An alternative, fully-managed path exists with no
native dependency: a BCL SslStream over TcpConnectionStream (~0.65x
the ring-native path, but the only one that does client certificates) - see the
tls-sslstream example.