diff --git a/Playground/Tls/MtlsKtlsPipes/Playground.Tls.MtlsKtlsPipes.csproj b/Playground/Tls/MtlsKtlsPipes/Playground.Tls.MtlsKtlsPipes.csproj
new file mode 100644
index 0000000..fd6eba1
--- /dev/null
+++ b/Playground/Tls/MtlsKtlsPipes/Playground.Tls.MtlsKtlsPipes.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net11.0
+ enable
+ enable
+ true
+ Playground.Tls.MtlsKtlsPipes
+ Playground.Tls.MtlsKtlsPipes
+
+
+
+
+
+
+
+
diff --git a/Playground/Tls/MtlsKtlsPipes/Program.cs b/Playground/Tls/MtlsKtlsPipes/Program.cs
new file mode 100644
index 0000000..7f525f4
--- /dev/null
+++ b/Playground/Tls/MtlsKtlsPipes/Program.cs
@@ -0,0 +1,226 @@
+using System.Buffers;
+using System.IO.Pipelines;
+using System.Text;
+using ioxide;
+using ioxide.tls;
+using Playground.Shared;
+
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+// tls-mtls-ktls-pipes - TLS where the CLIENT proves who it is too, served through an
+// IDuplexPipe, with the KERNEL encrypting outbound records.
+//
+// sudo modprobe tls # once per boot; kTLS needs the module
+// PLAYGROUND_CLIENT_CA=ca.crt dotnet run -c Release --project Playground/Tls/MtlsKtlsPipes
+// curl -k --cert client.crt --key client.key https://127.0.0.1:8443/
+// curl -k https://127.0.0.1:8443/ # no certificate: answered as anonymous
+//
+// Make a CA and a certificate it signs:
+//
+// openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 365 \
+// -subj "/CN=my CA" -addext "basicConstraints=critical,CA:TRUE"
+// openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=alice"
+// printf 'extendedKeyUsage=clientAuth\n' > c.cnf
+// openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
+// -out client.crt -days 365 -extfile c.cnf
+//
+// ClientCaPath is what turns this on. Leave it null and nothing is requested - the handshake is
+// exactly the one Playground/Tls/KtlsPipes performs.
+//
+// RequireClientCertificate is the interesting knob. OFF (the default) is the mixed port: anyone
+// connects, and the HANDLER decides what an unauthenticated peer may reach, which is why
+// TlsSession.PeerSubject exists. ON refuses at the handshake, before a byte of request is read -
+// cheaper, and blunter: there is no public route left.
+//
+// Either way a certificate that IS offered gets verified. "Optional" governs presenting nothing,
+// not presenting anything.
+//
+// This file is Playground/Tls/MtlsOpenSslPipes with KernelTx = true and nothing else changed -
+// diff them. That mTLS survives the swap untouched is the thing worth seeing: client
+// authentication belongs to the handshake, and kTLS only takes over afterwards.
+// Needs: ioxide
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+
+// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
+// Edit these. That is the whole mechanism - there is no config file and nothing else to find.
+
+ushort port = 8443; // https://127.0.0.1:8443/
+int reactors = Environment.ProcessorCount; // one ring per reactor, one reactor per core
+int bodyBytes = 8 * 1024; // TLS cost is per-byte, so a 2-byte "ok" would hide it
+
+Env.Override(ref port, ref reactors, ref bodyBytes);
+
+// The server's own certificate and key. Null generates a self-signed pair on first run.
+string? certOverride = null;
+string? keyOverride = null;
+
+Env.OverrideCert(ref certOverride, ref keyOverride);
+
+// The CA that CLIENT certificates are checked against - the switch that turns mTLS on.
+string? clientCaPath = Environment.GetEnvironmentVariable("PLAYGROUND_CLIENT_CA");
+
+// Refuse a client offering no certificate, during the handshake. Off by default: see the header.
+bool requireClientCertificate = Environment.GetEnvironmentVariable("PLAYGROUND_REQUIRE_CLIENT_CERT") == "1";
+
+// Per-connection recv buffer rings (kernel 6.12+) instead of one shared ring per reactor.
+bool incrementalBuffers = false;
+
+Env.OverrideIncremental(ref incrementalBuffers);
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+
+if (clientCaPath is null)
+{
+ Console.Error.WriteLine(
+ "set PLAYGROUND_CLIENT_CA to a PEM bundle of the CA that signs your client certificates.");
+ return 1;
+}
+
+(string certPath, string keyPath) = QuicCert.Ensure(certOverride, keyOverride);
+
+var config = new ServerConfig
+{
+ ReactorCount = reactors, // io_uring rings/threads - one per core
+ RingEntries = 8192, // SQ/CQ depth per ring
+ DualStack = false, // true = one IPv6 socket also accepts IPv4-mapped
+ RecvBufferSize = 32 * 1024, // bytes per shared recv buffer
+ RecvSlots = 4096, // shared recv buffer-ring depth
+ Incremental = incrementalBuffers ? new IncrementalOptions { MaxConnections = 1024, RecvSlots = 8, RecvBufferSize = 16 * 1024 } : null,
+ Udp = null, // no raw UDP sockets (TCP-only server)
+ Quic = null, // no QUIC transport - see Http3/*
+ Tcp = new TcpOptions
+ {
+ Port = port,
+ ExtraPorts = [], // extra listener ports (one handler, several doors)
+ ListenBacklog = 1024, // accept-queue depth per SO_REUSEPORT listener
+ WriteSlabSize = 16 * 1024, // per-connection write buffer before overflow kicks in
+ PoolMax = 1024, // pooled connection objects kept per reactor
+ WriteOverflow = WriteOverflowStrategy.Grow, // Grow = realloc one slab; Segmented = chain + vectored SENDMSG
+ ZeroCopySend = false, // SEND_ZC: kernel copies less, wins on large writes
+ RecvQueueEntries = 64, // per-connection recv completion queue depth
+ },
+};
+
+var tlsOptions = new TlsOptions
+{
+ CertificatePath = certPath, // PEM chain file (or CertificatePem for in-memory)
+ KeyPath = keyPath, // PEM key file (or KeyPem for in-memory)
+ Alpn = ["http/1.1"], // protocols this port serves, most-preferred first
+
+ ClientCaPath = clientCaPath, // trust anchors for CLIENT certificates (or ClientCaPem)
+ RequireClientCertificate = requireClientCertificate, // refuse a client with none, at the handshake
+
+ // The one line that differs from Playground/Tls/MtlsOpenSslPipes. The kernel encrypts outbound
+ // records, which needs `sudo modprobe tls` and pins this port to TLS 1.3, one ciphersuite, and
+ // no session resumption.
+ //
+ // It does NOT change anything above it. The client certificate is exchanged and verified during
+ // the HANDSHAKE, which OpenSSL performs either way - the kernel only takes over record crypto
+ // afterwards - so ClientCaPath, RequireClientCertificate and PeerSubject behave identically.
+ KernelTx = true,
+ KernelRx = false, // kTLS receive (experimental; requires KernelTx)
+};
+
+byte[] body = new byte[bodyBytes];
+"ioxide-mtls "u8.CopyTo(body);
+for (int i = "ioxide-mtls "u8.Length; i < bodyBytes; i++)
+{
+ body[i] = (byte)('a' + (i % 26));
+}
+
+// What an unauthenticated peer gets. Only reachable with RequireClientCertificate off - with it on
+// that client never completed a handshake, so nothing here ever runs for it.
+const string denied = "client certificate required";
+byte[] forbidden = Encoding.ASCII.GetBytes(
+ $"HTTP/1.1 403 Forbidden\r\nContent-Length: {denied.Length}\r\n\r\n{denied}");
+
+var threads = new Thread[config.ReactorCount];
+
+for (int i = 0; i < threads.Length; i++)
+{
+ var reactor = new Reactor(i, config);
+
+ reactor.OnStart = r => TlsService.Start(r, tlsOptions);
+
+ reactor.TcpHandle = async (r, conn) =>
+ {
+ TlsSession? tls = null;
+ try
+ {
+ tls = await r.GetService().AcceptAsync(conn);
+
+ // Who connected. Null means the peer offered no certificate, which only happens when
+ // RequireClientCertificate is off - a certificate that failed to verify never reaches
+ // here, because that fails the handshake.
+ //
+ // This is the whole point of the feature: enforcing an identity is half of it, and a
+ // server that can only enforce cannot authorise.
+ byte[] response = tls.PeerSubject is { } subject
+ ? [.. Encoding.ASCII.GetBytes(
+ $"HTTP/1.1 200 OK\r\nContent-Length: {bodyBytes}\r\nX-Client: {subject}\r\n\r\n"),
+ .. body]
+ : forbidden;
+
+ await using var pipe = new TlsConnectionDualPipe(conn, tls, ownsSession: false);
+
+ while (true)
+ {
+ ReadResult read = await pipe.Input.ReadAsync();
+
+ // Answer per REQUEST, not per read. TLS hands back RECORDS, so one request split
+ // across two records would otherwise draw two responses.
+ int answered = 0;
+ SequencePosition consumed = read.Buffer.Start;
+
+ var reader = new SequenceReader(read.Buffer);
+ while (reader.TryReadTo(out ReadOnlySequence _, "\r\n\r\n"u8, advancePastDelimiter: true))
+ {
+ consumed = reader.Position;
+ answered++;
+ }
+
+ // Consumed only whole requests; examined everything, so a partial head parks
+ // until more arrives instead of spinning on the same bytes.
+ pipe.Input.AdvanceTo(consumed, read.Buffer.End);
+
+ for (int n = 0; n < answered; n++)
+ {
+ // The identity is a property of the CONNECTION, so it is the same for every
+ // request on it - decided once, above the loop, not per request.
+ pipe.Output.Write(response);
+ }
+
+ if (answered > 0)
+ {
+ await pipe.Output.FlushAsync();
+ }
+
+ if (read.IsCompleted || read.IsCanceled)
+ {
+ return;
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"[tls-mtls-ktls-pipes] connection failed: {e.Message}");
+ }
+ finally
+ {
+ tls?.Dispose();
+ conn.DecRef();
+ }
+ };
+
+ threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
+ threads[i].Start();
+}
+
+Console.WriteLine($"[tls-mtls-ktls-pipes] {config.ReactorCount} reactors on :{config.Tcp!.Port}, "
+ + $"client CA {clientCaPath}, "
+ + $"{(requireClientCertificate ? "certificate REQUIRED" : "certificate optional")}, tx=kernel");
+
+foreach (Thread thread in threads)
+{
+ thread.Join();
+}
+
+return 0;
diff --git a/Playground/Tls/MtlsOpenSslPipes/Playground.Tls.MtlsOpenSslPipes.csproj b/Playground/Tls/MtlsOpenSslPipes/Playground.Tls.MtlsOpenSslPipes.csproj
new file mode 100644
index 0000000..81cafff
--- /dev/null
+++ b/Playground/Tls/MtlsOpenSslPipes/Playground.Tls.MtlsOpenSslPipes.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net11.0
+ enable
+ enable
+ true
+ Playground.Tls.MtlsOpenSslPipes
+ Playground.Tls.MtlsOpenSslPipes
+
+
+
+
+
+
+
+
diff --git a/Playground/Tls/MtlsOpenSslPipes/Program.cs b/Playground/Tls/MtlsOpenSslPipes/Program.cs
new file mode 100644
index 0000000..8d1e7c1
--- /dev/null
+++ b/Playground/Tls/MtlsOpenSslPipes/Program.cs
@@ -0,0 +1,219 @@
+using System.Buffers;
+using System.IO.Pipelines;
+using System.Text;
+using ioxide;
+using ioxide.tls;
+using Playground.Shared;
+
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+// tls-mtls-openssl-pipes - TLS where the CLIENT proves who it is too, served through an
+// IDuplexPipe, with OpenSSL doing the crypto in userspace.
+//
+// PLAYGROUND_CLIENT_CA=ca.crt dotnet run -c Release --project Playground/Tls/MtlsOpenSslPipes
+// curl -k --cert client.crt --key client.key https://127.0.0.1:8443/
+// curl -k https://127.0.0.1:8443/ # no certificate: answered as anonymous
+//
+// Make a CA and a certificate it signs:
+//
+// openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 365 \
+// -subj "/CN=my CA" -addext "basicConstraints=critical,CA:TRUE"
+// openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=alice"
+// printf 'extendedKeyUsage=clientAuth\n' > c.cnf
+// openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
+// -out client.crt -days 365 -extfile c.cnf
+//
+// ClientCaPath is what turns this on. Leave it null and nothing is requested - the handshake is
+// exactly the one Playground/Tls/OpenSslPipes performs.
+//
+// RequireClientCertificate is the interesting knob. OFF (the default) is the mixed port: anyone
+// connects, and the HANDLER decides what an unauthenticated peer may reach, which is why
+// TlsSession.PeerSubject exists. ON refuses at the handshake, before a byte of request is read -
+// cheaper, and blunter: there is no public route left.
+//
+// Either way a certificate that IS offered gets verified. "Optional" governs presenting nothing,
+// not presenting anything.
+//
+// Playground/Tls/MtlsKtlsPipes is this file with KernelTx = true. The client half of the same
+// handshake is Playground/Clients/Https. Needs: ioxide
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+
+// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
+// Edit these. That is the whole mechanism - there is no config file and nothing else to find.
+
+ushort port = 8443; // https://127.0.0.1:8443/
+int reactors = Environment.ProcessorCount; // one ring per reactor, one reactor per core
+int bodyBytes = 8 * 1024; // TLS cost is per-byte, so a 2-byte "ok" would hide it
+
+Env.Override(ref port, ref reactors, ref bodyBytes);
+
+// The server's own certificate and key. Null generates a self-signed pair on first run.
+string? certOverride = null;
+string? keyOverride = null;
+
+Env.OverrideCert(ref certOverride, ref keyOverride);
+
+// The CA that CLIENT certificates are checked against - the switch that turns mTLS on.
+string? clientCaPath = Environment.GetEnvironmentVariable("PLAYGROUND_CLIENT_CA");
+
+// Refuse a client offering no certificate, during the handshake. Off by default: see the header.
+bool requireClientCertificate = Environment.GetEnvironmentVariable("PLAYGROUND_REQUIRE_CLIENT_CERT") == "1";
+
+// Per-connection recv buffer rings (kernel 6.12+) instead of one shared ring per reactor.
+bool incrementalBuffers = false;
+
+Env.OverrideIncremental(ref incrementalBuffers);
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+
+if (clientCaPath is null)
+{
+ Console.Error.WriteLine(
+ "set PLAYGROUND_CLIENT_CA to a PEM bundle of the CA that signs your client certificates.");
+ return 1;
+}
+
+(string certPath, string keyPath) = QuicCert.Ensure(certOverride, keyOverride);
+
+var config = new ServerConfig
+{
+ ReactorCount = reactors, // io_uring rings/threads - one per core
+ RingEntries = 8192, // SQ/CQ depth per ring
+ DualStack = false, // true = one IPv6 socket also accepts IPv4-mapped
+ RecvBufferSize = 32 * 1024, // bytes per shared recv buffer
+ RecvSlots = 4096, // shared recv buffer-ring depth
+ Incremental = incrementalBuffers ? new IncrementalOptions { MaxConnections = 1024, RecvSlots = 8, RecvBufferSize = 16 * 1024 } : null,
+ Udp = null, // no raw UDP sockets (TCP-only server)
+ Quic = null, // no QUIC transport - see Http3/*
+ Tcp = new TcpOptions
+ {
+ Port = port,
+ ExtraPorts = [], // extra listener ports (one handler, several doors)
+ ListenBacklog = 1024, // accept-queue depth per SO_REUSEPORT listener
+ WriteSlabSize = 16 * 1024, // per-connection write buffer before overflow kicks in
+ PoolMax = 1024, // pooled connection objects kept per reactor
+ WriteOverflow = WriteOverflowStrategy.Grow, // Grow = realloc one slab; Segmented = chain + vectored SENDMSG
+ ZeroCopySend = false, // SEND_ZC: kernel copies less, wins on large writes
+ RecvQueueEntries = 64, // per-connection recv completion queue depth
+ },
+};
+
+var tlsOptions = new TlsOptions
+{
+ CertificatePath = certPath, // PEM chain file (or CertificatePem for in-memory)
+ KeyPath = keyPath, // PEM key file (or KeyPem for in-memory)
+ Alpn = ["http/1.1"], // protocols this port serves, most-preferred first
+
+ ClientCaPath = clientCaPath, // trust anchors for CLIENT certificates (or ClientCaPem)
+ RequireClientCertificate = requireClientCertificate, // refuse a client with none, at the handshake
+
+ // KernelTx stays false (the default). OpenSSL encrypts and decrypts, so nothing here needs the
+ // 'tls' kernel module. Client verification is unaffected by that choice either way - the
+ // certificate is exchanged during the handshake, which OpenSSL always performs.
+ KernelTx = false,
+ KernelRx = false, // kTLS receive (experimental; requires KernelTx)
+};
+
+byte[] body = new byte[bodyBytes];
+"ioxide-mtls "u8.CopyTo(body);
+for (int i = "ioxide-mtls "u8.Length; i < bodyBytes; i++)
+{
+ body[i] = (byte)('a' + (i % 26));
+}
+
+// What an unauthenticated peer gets. Only reachable with RequireClientCertificate off - with it on
+// that client never completed a handshake, so nothing here ever runs for it.
+const string denied = "client certificate required";
+byte[] forbidden = Encoding.ASCII.GetBytes(
+ $"HTTP/1.1 403 Forbidden\r\nContent-Length: {denied.Length}\r\n\r\n{denied}");
+
+var threads = new Thread[config.ReactorCount];
+
+for (int i = 0; i < threads.Length; i++)
+{
+ var reactor = new Reactor(i, config);
+
+ reactor.OnStart = r => TlsService.Start(r, tlsOptions);
+
+ reactor.TcpHandle = async (r, conn) =>
+ {
+ TlsSession? tls = null;
+ try
+ {
+ tls = await r.GetService().AcceptAsync(conn);
+
+ // Who connected. Null means the peer offered no certificate, which only happens when
+ // RequireClientCertificate is off - a certificate that failed to verify never reaches
+ // here, because that fails the handshake.
+ //
+ // This is the whole point of the feature: enforcing an identity is half of it, and a
+ // server that can only enforce cannot authorise.
+ byte[] response = tls.PeerSubject is { } subject
+ ? [.. Encoding.ASCII.GetBytes(
+ $"HTTP/1.1 200 OK\r\nContent-Length: {bodyBytes}\r\nX-Client: {subject}\r\n\r\n"),
+ .. body]
+ : forbidden;
+
+ await using var pipe = new TlsConnectionDualPipe(conn, tls, ownsSession: false);
+
+ while (true)
+ {
+ ReadResult read = await pipe.Input.ReadAsync();
+
+ // Answer per REQUEST, not per read. TLS hands back RECORDS, so one request split
+ // across two records would otherwise draw two responses.
+ int answered = 0;
+ SequencePosition consumed = read.Buffer.Start;
+
+ var reader = new SequenceReader(read.Buffer);
+ while (reader.TryReadTo(out ReadOnlySequence _, "\r\n\r\n"u8, advancePastDelimiter: true))
+ {
+ consumed = reader.Position;
+ answered++;
+ }
+
+ // Consumed only whole requests; examined everything, so a partial head parks
+ // until more arrives instead of spinning on the same bytes.
+ pipe.Input.AdvanceTo(consumed, read.Buffer.End);
+
+ for (int n = 0; n < answered; n++)
+ {
+ // The identity is a property of the CONNECTION, so it is the same for every
+ // request on it - decided once, above the loop, not per request.
+ pipe.Output.Write(response);
+ }
+
+ if (answered > 0)
+ {
+ await pipe.Output.FlushAsync();
+ }
+
+ if (read.IsCompleted || read.IsCanceled)
+ {
+ return;
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"[tls-mtls-openssl-pipes] connection failed: {e.Message}");
+ }
+ finally
+ {
+ tls?.Dispose();
+ conn.DecRef();
+ }
+ };
+
+ threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
+ threads[i].Start();
+}
+
+Console.WriteLine($"[tls-mtls-openssl-pipes] {config.ReactorCount} reactors on :{config.Tcp!.Port}, "
+ + $"client CA {clientCaPath}, "
+ + $"{(requireClientCertificate ? "certificate REQUIRED" : "certificate optional")}, tx=openssl");
+
+foreach (Thread thread in threads)
+{
+ thread.Join();
+}
+
+return 0;
diff --git a/ioxide.slnx b/ioxide.slnx
index 934e56f..153f63e 100644
--- a/ioxide.slnx
+++ b/ioxide.slnx
@@ -48,6 +48,8 @@
+
+
diff --git a/src/ioxide/Tls/Interop/OpenSsl.cs b/src/ioxide/Tls/Interop/OpenSsl.cs
index a1e0537..4d52724 100644
--- a/src/ioxide/Tls/Interop/OpenSsl.cs
+++ b/src/ioxide/Tls/Interop/OpenSsl.cs
@@ -20,6 +20,12 @@ internal static unsafe partial class OpenSsl
public const int SSL_TLSEXT_ERR_NOACK = 3;
public const int CRYPTO_EX_INDEX_SSL = 0;
+ // Client-certificate verification (mutual TLS).
+ public const int SSL_VERIFY_NONE = 0x00;
+ public const int SSL_VERIFY_PEER = 0x01;
+ public const int SSL_VERIFY_FAIL_IF_NO_PEER_CERT = 0x02;
+ public const long X509_V_OK = 0;
+
[LibraryImport(Ssl)] public static partial nint TLS_server_method();
[LibraryImport(Ssl)] public static partial nint SSL_CTX_new(nint method);
[LibraryImport(Ssl, StringMarshalling = StringMarshalling.Utf8)]
@@ -33,6 +39,43 @@ internal static unsafe partial class OpenSsl
[LibraryImport(Ssl)] public static partial void SSL_CTX_set_keylog_callback(nint ctx, nint cb);
[LibraryImport(Ssl)] public static partial void SSL_CTX_set_alpn_select_cb(nint ctx, nint cb, nint arg);
+ // --- client certificates (mutual TLS) ---------------------------------------------------------
+
+ ///
+ /// Ask for a client certificate and decide what a failed chain means. Mode
+ /// alone requests one and accepts a client that sends none;
+ /// adding refuses that client at the handshake.
+ /// With no callback, a chain that does not validate fails the handshake outright - there is
+ /// nothing to prompt and nowhere to fall back to.
+ ///
+ [LibraryImport(Ssl)] public static partial void SSL_CTX_set_verify(nint ctx, int mode, nint callback);
+
+ [LibraryImport(Ssl, StringMarshalling = StringMarshalling.Utf8)]
+ public static partial int SSL_CTX_load_verify_locations(nint ctx, string? caFile, string? caPath);
+
+ /// The context's trust store, for adding anchors parsed from memory.
+ [LibraryImport(Ssl)] public static partial nint SSL_CTX_get_cert_store(nint ctx);
+
+ [LibraryImport(Crypto)] public static partial int X509_STORE_add_cert(nint store, nint x509);
+
+ ///
+ /// Names sent in the CertificateRequest so a client holding several certificates can pick the
+ /// one this server will accept. Without it the client guesses.
+ ///
+ [LibraryImport(Ssl, StringMarshalling = StringMarshalling.Utf8)]
+ public static partial nint SSL_load_client_CA_file(string file);
+
+ [LibraryImport(Ssl)] public static partial void SSL_CTX_set_client_CA_list(nint ctx, nint list);
+
+ /// The peer's leaf certificate, borrowed - no reference taken, so it is not freed.
+ [LibraryImport(Ssl)] public static partial nint SSL_get0_peer_certificate(nint ssl);
+
+ /// X509_V_OK, or why the chain was rejected.
+ [LibraryImport(Ssl)] public static partial long SSL_get_verify_result(nint ssl);
+
+ [LibraryImport(Crypto)] public static partial nint X509_get_subject_name(nint x509);
+ [LibraryImport(Crypto)] public static partial nint X509_NAME_oneline(nint name, byte* buf, int size);
+
[LibraryImport(Ssl)] public static partial nint SSL_new(nint ctx);
[LibraryImport(Ssl)] public static partial void SSL_free(nint ssl);
[LibraryImport(Ssl)] public static partial void SSL_set_accept_state(nint ssl);
diff --git a/src/ioxide/Tls/TlsOptions.cs b/src/ioxide/Tls/TlsOptions.cs
index 6dd9bfd..f08f323 100644
--- a/src/ioxide/Tls/TlsOptions.cs
+++ b/src/ioxide/Tls/TlsOptions.cs
@@ -33,6 +33,41 @@ public sealed class TlsOptions
///
public string[] Alpn { get; init; } = ["http/1.1"];
+ ///
+ /// PEM bundle of trust anchors that CLIENT certificates are validated against - mutual TLS.
+ /// Null (the default) means no client certificate is ever requested and the handshake is
+ /// exactly what it was.
+ ///
+ /// Setting this asks every client for a certificate and rejects one whose chain does not
+ /// validate. Whether a client offering NOTHING is also rejected is
+ /// .
+ ///
+ /// The file's subject names are also sent in the CertificateRequest, so a client holding
+ /// several certificates can pick the one this server accepts rather than guessing. Anchors
+ /// supplied through are trusted identically but send no such hint -
+ /// OpenSSL builds that list from a file.
+ ///
+ public string? ClientCaPath { get; init; }
+
+ ///
+ /// Client trust anchors as PEM text - the in-memory alternative to ,
+ /// matching how the server's own certificate can come from either. Set at most one of the two.
+ ///
+ public string? ClientCaPem { get; init; }
+
+ ///
+ /// With client anchors configured, whether a client that presents NO certificate is refused
+ /// during the handshake.
+ ///
+ /// False - the default - lets it connect unauthenticated and leaves the decision to the
+ /// application, which reads and can serve a public route
+ /// while refusing a protected one. True refuses at the handshake, before a single byte of
+ /// request has been read.
+ ///
+ /// Ignored when no anchors are set: there would be nothing to validate against.
+ ///
+ public bool RequireClientCertificate { get; init; }
+
///
/// Let the kernel decrypt inbound records too. Off by default, experimental, and it requires
/// : RX is programmed at the same handoff as TX and shares the TCP_ULP
diff --git a/src/ioxide/Tls/TlsService.cs b/src/ioxide/Tls/TlsService.cs
index 9ce7426..7391f16 100644
--- a/src/ioxide/Tls/TlsService.cs
+++ b/src/ioxide/Tls/TlsService.cs
@@ -63,6 +63,22 @@ public static TlsService Start(Reactor reactor, TlsOptions options, bool registe
"Exactly one key source: set KeyPath or KeyPem.", nameof(options));
}
+ // Client anchors are optional, but two sources for them is a mistake worth naming rather
+ // than resolving by precedence.
+ if (options.ClientCaPath is not null && options.ClientCaPem is not null)
+ {
+ throw new ArgumentException(
+ "At most one client CA source: set ClientCaPath or ClientCaPem, not both.", nameof(options));
+ }
+
+ // Requiring a certificate with nothing to validate it against would refuse every client
+ // that sends none and accept any that sends anything - the opposite of what it reads as.
+ if (options.RequireClientCertificate && options.ClientCaPath is null && options.ClientCaPem is null)
+ {
+ throw new ArgumentException(
+ "RequireClientCertificate needs trust anchors: set ClientCaPath or ClientCaPem.", nameof(options));
+ }
+
nint ctx = OpenSsl.SSL_CTX_new(OpenSsl.TLS_server_method());
if (ctx == 0)
{
@@ -88,6 +104,7 @@ public static TlsService Start(Reactor reactor, TlsOptions options, bool registe
}
LoadCertificate(ctx, options);
+ ConfigureClientVerification(ctx, options);
// The ALPN protocol to select is handed to the (static) callback via its arg, so the
// configured TlsOptions.Alpn is honored instead of being hard-coded.
@@ -111,6 +128,122 @@ public static TlsService Start(Reactor reactor, TlsOptions options, bool registe
return service;
}
+ ///
+ /// Client-certificate verification. Does nothing unless anchors are configured, so the
+ /// handshake for everyone else is byte-for-byte what it was.
+ ///
+ ///
+ /// This is a property of the SSL_CTX and therefore of the port: TLS settles client
+ /// authentication during the handshake, and while TLS 1.3 has post-handshake authentication,
+ /// nothing here uses it. A route that wants to authenticate reads
+ /// from a handshake that already happened.
+ ///
+ /// Orthogonal to kTLS. The certificate is exchanged and validated during the handshake, which
+ /// OpenSSL performs either way - the kernel only takes over record crypto afterwards - so this
+ /// composes with and
+ /// unchanged.
+ ///
+ private static unsafe void ConfigureClientVerification(nint ctx, TlsOptions options)
+ {
+ if (options.ClientCaPath is null && options.ClientCaPem is null)
+ {
+ return;
+ }
+
+ if (options.ClientCaPath is not null)
+ {
+ if (OpenSsl.SSL_CTX_load_verify_locations(ctx, options.ClientCaPath, null) != 1)
+ {
+ throw new IOException(
+ $"could not load client CA '{options.ClientCaPath}': {OpenSsl.LastError()}");
+ }
+
+ // Tell the client which issuers we accept. Failure here is not fatal - it costs the
+ // hint, not the verification - so a CA file OpenSSL can trust but not enumerate still
+ // works for a client with a single certificate.
+ nint names = OpenSsl.SSL_load_client_CA_file(options.ClientCaPath);
+ if (names != 0)
+ {
+ OpenSsl.SSL_CTX_set_client_CA_list(ctx, names);
+ }
+ else
+ {
+ OpenSsl.ERR_clear_error();
+ }
+ }
+ else
+ {
+ AddTrustAnchorsPem(ctx, options.ClientCaPem!);
+ }
+
+ int mode = OpenSsl.SSL_VERIFY_PEER;
+ if (options.RequireClientCertificate)
+ {
+ mode |= OpenSsl.SSL_VERIFY_FAIL_IF_NO_PEER_CERT;
+ }
+
+ // No callback: a chain that does not validate fails the handshake. There is no prompt to
+ // fall back to and no partial trust worth inventing.
+ OpenSsl.SSL_CTX_set_verify(ctx, mode, 0);
+ }
+
+ // Trust anchors from PEM text, added straight to the context's store - the in-memory mirror of
+ // load_verify_locations, for hosts that carry a CA bundle as data rather than as a file.
+ private static unsafe void AddTrustAnchorsPem(nint ctx, string pem)
+ {
+ nint store = OpenSsl.SSL_CTX_get_cert_store(ctx);
+ if (store == 0)
+ {
+ throw new IOException($"SSL_CTX_get_cert_store: {OpenSsl.LastError()}");
+ }
+
+ byte[] bytes = System.Text.Encoding.ASCII.GetBytes(pem);
+ int added = 0;
+
+ fixed (byte* p = bytes)
+ {
+ nint bio = OpenSsl.BIO_new_mem_buf(p, bytes.Length);
+ if (bio == 0)
+ {
+ throw new IOException($"BIO_new_mem_buf: {OpenSsl.LastError()}");
+ }
+
+ try
+ {
+ // A bundle may hold several anchors; read until the BIO is exhausted.
+ while (true)
+ {
+ nint cert = OpenSsl.PEM_read_bio_X509(bio, 0, 0, 0);
+ if (cert == 0)
+ {
+ break;
+ }
+
+ int ok = OpenSsl.X509_STORE_add_cert(store, cert);
+ OpenSsl.X509_free(cert); // the store took its own reference
+
+ if (ok != 1)
+ {
+ throw new IOException($"X509_STORE_add_cert: {OpenSsl.LastError()}");
+ }
+ added++;
+ }
+ }
+ finally
+ {
+ OpenSsl.BIO_free(bio);
+ }
+ }
+
+ // Running off the end of the last certificate leaves a "no start line" error behind.
+ OpenSsl.ERR_clear_error();
+
+ if (added == 0)
+ {
+ throw new IOException("ClientCaPem contained no certificates.");
+ }
+ }
+
// Certificate and key, from whichever source the options carry. The file route is OpenSSL's
// own loaders; the in-memory route reads the same PEM through a memory BIO.
private static void LoadCertificate(nint ctx, TlsOptions options)
@@ -284,6 +417,10 @@ public async ValueTask AcceptAsync(TcpConnection conn)
// decides which protocol loop to run.
session.CaptureAlpn();
+ // Same moment, same reason: the peer identity exists only after the handshake, and a
+ // handler that authorises per route needs it before it reads a request.
+ session.CapturePeerCertificate();
+
// Count BEFORE draining: these are the records the handshake pulled off the socket, so
// they are invisible to the kernel and the RX sequence number has to skip past them.
int consumedRecords = session.CountPendingRecords(out bool partialRecord);
diff --git a/src/ioxide/Tls/TlsSession.cs b/src/ioxide/Tls/TlsSession.cs
index b95d7f3..c6fae79 100644
--- a/src/ioxide/Tls/TlsSession.cs
+++ b/src/ioxide/Tls/TlsSession.cs
@@ -66,6 +66,47 @@ internal void CaptureAlpn()
: System.Text.Encoding.ASCII.GetString(data, (int)length);
}
+ ///
+ /// Subject of the client certificate this peer presented, or null when it presented none -
+ /// which is possible whenever is set but
+ /// is not.
+ ///
+ /// A value here means the chain VALIDATED: an invalid one fails the handshake, so a connection
+ /// that reached a handler never carries a certificate that was merely offered. Null means
+ /// unauthenticated, and is the whole decision a handler serving a mixed port has to make.
+ ///
+ public string? PeerSubject { get; private set; }
+
+ internal void CapturePeerCertificate()
+ {
+ // Borrowed, not owned - get0 takes no reference, so there is nothing to free.
+ nint cert = OpenSsl.SSL_get0_peer_certificate(_ssl);
+ if (cert == 0)
+ {
+ PeerSubject = null;
+ return;
+ }
+
+ // Belt and braces: with SSL_VERIFY_PEER and no callback OpenSSL has already failed the
+ // handshake on a bad chain, so this cannot report a name that was not verified.
+ if (OpenSsl.SSL_get_verify_result(_ssl) != OpenSsl.X509_V_OK)
+ {
+ PeerSubject = null;
+ return;
+ }
+
+ nint name = OpenSsl.X509_get_subject_name(cert);
+ if (name == 0)
+ {
+ PeerSubject = null;
+ return;
+ }
+
+ byte* buffer = stackalloc byte[256];
+ nint result = OpenSsl.X509_NAME_oneline(name, buffer, 256);
+ PeerSubject = result == 0 ? null : Marshal.PtrToStringUTF8((nint)buffer);
+ }
+
internal TlsSession(nint ssl, nint rbio, nint wbio)
{
_ssl = ssl;
diff --git a/tests/Ioxide.Tests.Harness/TestServer.cs b/tests/Ioxide.Tests.Harness/TestServer.cs
index b5152d5..975ae05 100644
--- a/tests/Ioxide.Tests.Harness/TestServer.cs
+++ b/tests/Ioxide.Tests.Harness/TestServer.cs
@@ -451,6 +451,45 @@ public static (int Status, string Body) GetTls(int port, string path, int timeou
return ReadResponse(ssl);
}
+ ///
+ /// Like , but presenting a client certificate - mutual TLS. Pass null to
+ /// present none, which is how "the server demanded one and we had nothing" is driven.
+ ///
+ ///
+ /// SslStream is the client here on purpose: a passing test means ioxide's server agrees with an
+ /// independent implementation rather than only with itself. A refused handshake surfaces as an
+ /// or an depending on which side
+ /// noticed first, so callers assert on "it threw" rather than on which one.
+ ///
+ public static (int Status, string Body) GetTlsClientCert(
+ int port, string path, string? certPath, string? keyPath, int timeoutMs = 6000)
+ {
+ using var client = new TcpClient();
+ client.Connect("127.0.0.1", port);
+ client.ReceiveTimeout = timeoutMs;
+
+ var certificates = new X509CertificateCollection();
+ if (certPath is not null && keyPath is not null)
+ {
+ using X509Certificate2 pem = X509Certificate2.CreateFromPemFile(certPath, keyPath);
+
+ // SslStream on Linux needs the key associated through a PFX round-trip; a PEM-built
+ // certificate carries it in a form the handshake will not use directly.
+ certificates.Add(X509CertificateLoader.LoadPkcs12(pem.Export(X509ContentType.Pfx), null));
+ }
+
+ using var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false, (_, _, _, _) => true);
+ ssl.AuthenticateAsClient(new SslClientAuthenticationOptions
+ {
+ TargetHost = "localhost",
+ EnabledSslProtocols = SslProtocols.Tls13,
+ ClientCertificates = certificates,
+ });
+
+ Send(ssl, path);
+ return ReadResponse(ssl);
+ }
+
///
/// Split ONE request across several TLS records - each SslStream.Write emits its own complete
/// record - and report how many responses came back.
diff --git a/tests/Ioxide.Tests.Tls/MutualTlsTests.cs b/tests/Ioxide.Tests.Tls/MutualTlsTests.cs
new file mode 100644
index 0000000..3699e4d
--- /dev/null
+++ b/tests/Ioxide.Tests.Tls/MutualTlsTests.cs
@@ -0,0 +1,296 @@
+using System.Text;
+using ioxide;
+using ioxide.tls;
+
+namespace Ioxide.Tests;
+
+///
+/// Mutual TLS on the TCP side: the server asking the CLIENT for a certificate.
+///
+/// Driven with SslStream as the client, so a passing test means ioxide agrees with an independent
+/// implementation rather than only with itself. The handler reports what it sees, which is the
+/// point - enforcing an identity is only half of it if nothing can act on one.
+///
+/// Every case runs twice where the kernel module allows it: once on the OpenSSL path and once with
+/// KernelTx. mTLS is settled during the handshake, which OpenSSL performs either way, so the two
+/// must agree - and asserting that is cheaper than reasoning about it.
+///
+internal static class MutualTlsTests
+{
+ public static void Register(Runner runner, bool ktls)
+ {
+ foreach ((string label, bool kernelTx) in Paths(ktls))
+ {
+ runner.Test($"mtls{label}: a client certificate is verified and its subject reaches the handler", () =>
+ {
+ (string ca, string serverCert, string serverKey, string clientCert, string clientKey, _, _)
+ = TestCert.EnsureMutualTls();
+
+ int port = TestServer.Start(IdentityHandler, r => TlsService.Start(r, new TlsOptions
+ {
+ CertificatePath = serverCert,
+ KeyPath = serverKey,
+ ClientCaPath = ca,
+ RequireClientCertificate = true,
+ KernelTx = kernelTx,
+ }));
+
+ (int status, string body) = Client.GetTlsClientCert(port, "/who", clientCert, clientKey);
+
+ Assert.Equal(200, status);
+ Assert.True(body.Contains("alice"), $"the handler should have seen CN=alice, got: {body}");
+ });
+
+ runner.Test($"mtls{label}: a client with no certificate is refused when one is required", () =>
+ {
+ (string ca, string serverCert, string serverKey, _, _, _, _) = TestCert.EnsureMutualTls();
+
+ int port = TestServer.Start(IdentityHandler, r => TlsService.Start(r, new TlsOptions
+ {
+ CertificatePath = serverCert,
+ KeyPath = serverKey,
+ ClientCaPath = ca,
+ RequireClientCertificate = true,
+ KernelTx = kernelTx,
+ }));
+
+ Assert.True(HandshakeFails(port, null, null),
+ "a client presenting no certificate should have been refused");
+ });
+
+ runner.Test($"mtls{label}: a certificate from another CA is refused", () =>
+ {
+ (string ca, string serverCert, string serverKey, _, _, string rogueCert, string rogueKey)
+ = TestCert.EnsureMutualTls();
+
+ int port = TestServer.Start(IdentityHandler, r => TlsService.Start(r, new TlsOptions
+ {
+ CertificatePath = serverCert,
+ KeyPath = serverKey,
+ ClientCaPath = ca,
+ RequireClientCertificate = true,
+ KernelTx = kernelTx,
+ }));
+
+ // Well-formed and correctly signed - by a CA this server does not trust. Holding a
+ // certificate is not the same as holding one that means anything here.
+ Assert.True(HandshakeFails(port, rogueCert, rogueKey),
+ "a certificate from an untrusted CA should have been refused");
+ });
+ }
+
+ runner.Test("mtls: without RequireClientCertificate an anonymous client connects, unauthenticated", () =>
+ {
+ (string ca, string serverCert, string serverKey, _, _, _, _) = TestCert.EnsureMutualTls();
+
+ // The mixed port: anyone may connect, and the handler decides what an unauthenticated
+ // one is allowed to reach.
+ int port = TestServer.Start(IdentityHandler, r => TlsService.Start(r, new TlsOptions
+ {
+ CertificatePath = serverCert,
+ KeyPath = serverKey,
+ ClientCaPath = ca,
+ RequireClientCertificate = false,
+ }));
+
+ (int status, string body) = Client.GetTlsClientCert(port, "/who", null, null);
+
+ Assert.Equal(200, status);
+ Assert.Equal("anonymous", body);
+ });
+
+ runner.Test("mtls: a certificate still verifies when one is optional", () =>
+ {
+ (string ca, string serverCert, string serverKey, string clientCert, string clientKey, _, _)
+ = TestCert.EnsureMutualTls();
+
+ int port = TestServer.Start(IdentityHandler, r => TlsService.Start(r, new TlsOptions
+ {
+ CertificatePath = serverCert,
+ KeyPath = serverKey,
+ ClientCaPath = ca,
+ RequireClientCertificate = false,
+ }));
+
+ (int status, string body) = Client.GetTlsClientCert(port, "/who", clientCert, clientKey);
+
+ Assert.Equal(200, status);
+ Assert.True(body.Contains("alice"), $"an optional certificate must still be read, got: {body}");
+ });
+
+ runner.Test("mtls: a rogue certificate is refused even when one is only optional", () =>
+ {
+ (string ca, string serverCert, string serverKey, _, _, string rogueCert, string rogueKey)
+ = TestCert.EnsureMutualTls();
+
+ // "Optional" governs presenting NOTHING. A certificate that is offered is always
+ // verified - otherwise the option would read as "trust anything".
+ int port = TestServer.Start(IdentityHandler, r => TlsService.Start(r, new TlsOptions
+ {
+ CertificatePath = serverCert,
+ KeyPath = serverKey,
+ ClientCaPath = ca,
+ RequireClientCertificate = false,
+ }));
+
+ Assert.True(HandshakeFails(port, rogueCert, rogueKey),
+ "an offered certificate must be verified whether or not one was required");
+ });
+
+ runner.Test("mtls: anchors from memory behave exactly as anchors from a file", () =>
+ {
+ (string ca, string serverCert, string serverKey, string clientCert, string clientKey, _, _)
+ = TestCert.EnsureMutualTls();
+
+ int port = TestServer.Start(IdentityHandler, r => TlsService.Start(r, new TlsOptions
+ {
+ CertificatePath = serverCert,
+ KeyPath = serverKey,
+ ClientCaPem = File.ReadAllText(ca),
+ RequireClientCertificate = true,
+ }));
+
+ (int status, string body) = Client.GetTlsClientCert(port, "/who", clientCert, clientKey);
+
+ Assert.Equal(200, status);
+ Assert.True(body.Contains("alice"), $"the in-memory anchor should verify the same client, got: {body}");
+ });
+
+ runner.Test("mtls: no client CA leaves the handshake exactly as it was", () =>
+ {
+ (_, _, _, string clientCert, string clientKey, _, _) = TestCert.EnsureMutualTls();
+ (string certPath, string keyPath) = TestCert.Ensure();
+
+ // No anchors configured: nothing is requested, so a client that HAS a certificate is
+ // never asked for it and connects anonymously.
+ int port = TestServer.Start(IdentityHandler, r => TlsService.Start(r, new TlsOptions
+ {
+ CertificatePath = certPath,
+ KeyPath = keyPath,
+ }));
+
+ (int status, string body) = Client.GetTlsClientCert(port, "/who", clientCert, clientKey);
+
+ Assert.Equal(200, status);
+ Assert.Equal("anonymous", body);
+ });
+
+ RegisterConfigurationErrors(runner);
+ }
+
+ // Configuration mistakes that should fail where they are written, not at some later handshake.
+ private static void RegisterConfigurationErrors(Runner runner)
+ {
+ runner.Test("mtls: requiring a certificate without anchors is refused", () =>
+ {
+ (string certPath, string keyPath) = TestCert.Ensure();
+
+ // Nothing to validate against: this would refuse every client sending none and accept
+ // anything from one that sends something - the opposite of what it reads as.
+ Assert.True(StartFails(new TlsOptions
+ {
+ CertificatePath = certPath,
+ KeyPath = keyPath,
+ RequireClientCertificate = true,
+ }), "RequireClientCertificate without anchors must be rejected");
+ });
+
+ runner.Test("mtls: two client CA sources are refused", () =>
+ {
+ (string ca, string serverCert, string serverKey, _, _, _, _) = TestCert.EnsureMutualTls();
+
+ Assert.True(StartFails(new TlsOptions
+ {
+ CertificatePath = serverCert,
+ KeyPath = serverKey,
+ ClientCaPath = ca,
+ ClientCaPem = File.ReadAllText(ca),
+ }), "ClientCaPath and ClientCaPem together must be rejected");
+ });
+
+ runner.Test("mtls: an unreadable client CA fails at startup", () =>
+ {
+ (string certPath, string keyPath) = TestCert.Ensure();
+
+ Assert.True(StartFails(new TlsOptions
+ {
+ CertificatePath = certPath,
+ KeyPath = keyPath,
+ ClientCaPath = "/nonexistent/ca.pem",
+ }), "a missing CA file must be reported at startup");
+ });
+ }
+
+ private static (string Label, bool KernelTx)[] Paths(bool ktls) =>
+ ktls ? [(" (openssl)", false), (" (ktls)", true)] : [(" (openssl)", false)];
+
+ ///
+ /// Whether the handshake was refused. Which exception arrives depends on which side noticed
+ /// first, so the assertion is on refusal rather than on its spelling.
+ ///
+ private static bool HandshakeFails(int port, string? certPath, string? keyPath)
+ {
+ try
+ {
+ Client.GetTlsClientCert(port, "/who", certPath, keyPath);
+ return false;
+ }
+ catch (Exception)
+ {
+ return true;
+ }
+ }
+
+ private static bool StartFails(TlsOptions options)
+ {
+ try
+ {
+ TestServer.Start(EmptyHandler, r => TlsService.Start(r, options));
+ return false;
+ }
+ catch (Exception)
+ {
+ return true;
+ }
+ }
+
+ // Answers with the authenticated identity, or "anonymous". Reading PeerSubject is the whole
+ // point of the feature: a server that can only enforce cannot authorise.
+ private static async Task IdentityHandler(Reactor reactor, TcpConnection connection)
+ {
+ TlsSession? session = null;
+ try
+ {
+ session = await reactor.GetService()!.AcceptAsync(connection);
+
+ while (true)
+ {
+ RecvSnapshot snapshot = await connection.ReadAsync();
+ if (snapshot.IsClosed)
+ {
+ return;
+ }
+
+ string subject = session.PeerSubject is { } peer ? peer : "anonymous";
+ byte[] body = Encoding.ASCII.GetBytes(subject);
+
+ session.Write(connection, Encoding.ASCII.GetBytes(
+ $"HTTP/1.1 200 OK\r\ncontent-length: {body.Length}\r\n\r\n{subject}"));
+
+ await connection.FlushAsync();
+ connection.ResetRead();
+ }
+ }
+ finally
+ {
+ session?.Dispose();
+ connection.DecRef();
+ }
+ }
+
+ private static Task EmptyHandler(Reactor reactor, TcpConnection connection)
+ {
+ connection.DecRef();
+ return Task.CompletedTask;
+ }
+}
diff --git a/tests/Ioxide.Tests.Tls/Program.cs b/tests/Ioxide.Tests.Tls/Program.cs
index a1100c9..4897c1a 100644
--- a/tests/Ioxide.Tests.Tls/Program.cs
+++ b/tests/Ioxide.Tests.Tls/Program.cs
@@ -21,6 +21,7 @@ private static int Main()
TlsTests.Register(runner, ktls);
DecryptFaultTests.Register(runner, ktls);
TlsPipeTests.Register(runner, ktls);
+ MutualTlsTests.Register(runner, ktls);
return runner.Summary();
}