From b74499cae5b915c67553e7a6396c625fbc15d6c4 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 02:20:38 +0100 Subject: [PATCH] feat(tls): mutual TLS on the TCP side, for both OpenSSL and kTLS ioxide could require client certificates over QUIC (#180) and not over TCP. The TCP server had no peer verification at all: no SSL_CTX_set_verify, no load_verify_locations, and nothing in TlsOptions to configure one. An https:// port could prove who the SERVER was and never ask the other question. TlsOptions gains ClientCaPath, ClientCaPem and RequireClientCertificate. Anchors from a file go through load_verify_locations; anchors from memory are parsed into the context's own store, mirroring how the server's certificate already comes from either. With a file we also send the CA names in the CertificateRequest, so a client holding several certificates picks the one this server accepts instead of guessing - a failure to enumerate them costs the hint, not the verification. RequireClientCertificate is the interesting distinction. Off, a client with no certificate still connects and TlsSession.PeerSubject is null, so a handler can serve a public route and refuse a protected one. On, it is refused at the handshake. Either way a certificate that IS offered is verified: "optional" governs presenting nothing, not presenting anything. PeerSubject is the other half. Enforcing an identity is not much use if nothing can read one, and a value there means the chain VALIDATED - an invalid one already failed the handshake, so a connection that reaches a handler never carries a merely-offered certificate. Orthogonal to kTLS, which is why both samples exist and differ by one line. The certificate is exchanged and verified during the handshake, which OpenSSL performs either way; the kernel only takes over record crypto afterwards. MtlsKtlsPipes is MtlsOpenSslPipes with KernelTx = true and nothing else. Nothing changes for anyone not using it: every path here sits behind "are anchors configured", and a server without them performs the handshake it performed before. Verified end to end with curl against the sample: valid client cert -> 200, X-Client: /CN=alice cert from a rogue CA -> tlsv1 alert unknown ca no cert, optional -> 403 from the handler no cert, required -> tlsv13 alert certificate required 11 new tests, driven with SslStream so a pass means agreeing with an independent implementation. Build clean; 25 unit, 23 tls and 28 http green. The three kTLS cases skip without `sudo modprobe tls`. --- .../Playground.Tls.MtlsKtlsPipes.csproj | 18 ++ Playground/Tls/MtlsKtlsPipes/Program.cs | 226 +++++++++++++ .../Playground.Tls.MtlsOpenSslPipes.csproj | 18 ++ Playground/Tls/MtlsOpenSslPipes/Program.cs | 219 +++++++++++++ ioxide.slnx | 2 + src/ioxide/Tls/Interop/OpenSsl.cs | 43 +++ src/ioxide/Tls/TlsOptions.cs | 35 +++ src/ioxide/Tls/TlsService.cs | 137 ++++++++ src/ioxide/Tls/TlsSession.cs | 41 +++ tests/Ioxide.Tests.Harness/TestServer.cs | 39 +++ tests/Ioxide.Tests.Tls/MutualTlsTests.cs | 296 ++++++++++++++++++ tests/Ioxide.Tests.Tls/Program.cs | 1 + 12 files changed, 1075 insertions(+) create mode 100644 Playground/Tls/MtlsKtlsPipes/Playground.Tls.MtlsKtlsPipes.csproj create mode 100644 Playground/Tls/MtlsKtlsPipes/Program.cs create mode 100644 Playground/Tls/MtlsOpenSslPipes/Playground.Tls.MtlsOpenSslPipes.csproj create mode 100644 Playground/Tls/MtlsOpenSslPipes/Program.cs create mode 100644 tests/Ioxide.Tests.Tls/MutualTlsTests.cs 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(); }