diff --git a/Playground/Http3/MutualTls/Playground.Http3.MutualTls.csproj b/Playground/Http3/MutualTls/Playground.Http3.MutualTls.csproj new file mode 100644 index 00000000..490383d8 --- /dev/null +++ b/Playground/Http3/MutualTls/Playground.Http3.MutualTls.csproj @@ -0,0 +1,20 @@ + + + + Exe + net11.0 + enable + enable + true + Playground.Http3.MutualTls + Playground.Http3.MutualTls + + + + + + + + + + diff --git a/Playground/Http3/MutualTls/Program.cs b/Playground/Http3/MutualTls/Program.cs new file mode 100644 index 00000000..9e7669e2 --- /dev/null +++ b/Playground/Http3/MutualTls/Program.cs @@ -0,0 +1,116 @@ +using System.Text; +using ioxide; +using ioxide.http3; +using ioxide.ngtcp2; +using Playground.Shared; + +// ───────────────────────────────────────────────────────────────────────────────────────────── +// http3-mtls - HTTP/3 where the CLIENT proves who it is too. The server verifies a client +// certificate during the QUIC handshake and the handler is told which peer it got. +// +// dotnet run -c Release --project Playground/Http3/MutualTls +// curl --http3 --cacert ca.crt --cert client.crt --key client.key https://localhost:8443/ +// +// Make a CA and two certificates it signs - one for the server, one for the client: +// +// 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 +// +// QUIC settles client authentication during the handshake, and RFC 9001 4.4 forbids doing it +// afterwards - so this is a property of the whole CONNECTION. There is no asking for a certificate +// later because a request reached a protected route; that needs a second port. +// Needs: ioxide, ioxide.ngtcp2, ioxide.http3 +// ───────────────────────────────────────────────────────────────────────────────────────────── + +// ── Knobs ──────────────────────────────────────────────────────────────────────────────────── +// Edit these. That is the whole mechanism - there is no config file and nothing else to find. + +ushort quicPort = 8443; +int reactors = Environment.ProcessorCount; + +Env.OverrideQuic(ref quicPort, ref reactors); + +// 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. This is what turns mTLS ON - leave it null +// and the server verifies nothing about the client, exactly as the other h3 samples do. +string? clientCaPath = Environment.GetEnvironmentVariable("PLAYGROUND_CLIENT_CA"); + +// Refuse a client that offers no certificate, during the handshake. Off, so a client without one +// still connects and the handler decides what it may see - which is the more useful default when +// only part of a site is protected. +bool requireClientCertificate = Environment.GetEnvironmentVariable("PLAYGROUND_REQUIRE_CLIENT_CERT") == "1"; + +// UDP receive slots per reactor: how many datagrams the ring can have outstanding at once. +int udpRecvSlots = 16; +// ───────────────────────────────────────────────────────────────────────────────────────────── + +(string certPath, string keyPath) = QuicCert.Ensure(certOverride, keyOverride); + +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; +} + +using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"], + clientCaPemPath: clientCaPath, requireClientCertificate: requireClientCertificate); + +var config = new ServerConfig +{ + ReactorCount = reactors, + Tcp = null, // QUIC only + Udp = new UdpOptions { RecvSlots = udpRecvSlots }, + Quic = new QuicOptions + { + Port = quicPort, + LocalCidLength = 8, + ConnectionFactory = engine.CreateFactory(), + }, +}; + +var threads = new Thread[config.ReactorCount]; + +for (int i = 0; i < threads.Length; i++) +{ + var reactor = new Reactor(i, config); + + reactor.QuicHandle = (_, connection) => + new Http3Connection(connection).RunAsync(_ => + { + // Read HERE, per request - not where the connection is accepted. That callback runs + // before the handshake finishes, so there is no identity yet at that point. + string? peer = (connection as QuicEngineConnection)?.PeerSubject; + + var response = new Http3Response + { + Body = Encoding.UTF8.GetBytes(peer is null + ? "anonymous\n" + : $"authenticated as {peer}\n"), + }; + response.Headers.Add(("content-type"u8.ToArray(), "text/plain"u8.ToArray())); + return response; + }); + + threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" }; + threads[i].Start(); +} + +Console.WriteLine($"[http3-mtls] {config.ReactorCount} reactors on :{quicPort}, " + + $"client CA {clientCaPath}, " + + $"client certificate {(requireClientCertificate ? "REQUIRED" : "optional")}"); + +foreach (Thread thread in threads) +{ + thread.Join(); +} + +return 0; diff --git a/docs/assets/style.css b/docs/assets/style.css index 7bca2475..a352e83d 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -220,6 +220,7 @@ nav.top .links a.gh svg { display: block; } #tab-h2cstls:checked ~ .ex-menu label[for="tab-h2cstls"], #tab-h2bcl:checked ~ .ex-menu label[for="tab-h2bcl"], #tab-h3csstream:checked ~ .ex-menu label[for="tab-h3csstream"], +#tab-h3mtls:checked ~ .ex-menu label[for="tab-h3mtls"], #tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"], #tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"], #tab-h3buf:checked ~ .ex-menu label[for="tab-h3buf"], @@ -353,6 +354,7 @@ nav.top .links a.gh svg { display: block; } #tab-h2cstls:checked ~ .pane-h2cstls { display: block; } #tab-h2bcl:checked ~ .pane-h2bcl { display: block; } #tab-h3csstream:checked ~ .pane-h3csstream { display: block; } +#tab-h3mtls:checked ~ .pane-h3mtls { display: block; } #tab-h3cs:checked ~ .pane-h3cs { display: block; } #tab-h3stream:checked ~ .pane-h3stream { display: block; } #tab-h3buf:checked ~ .pane-h3buf { display: block; } @@ -491,6 +493,7 @@ nav.top .links a.gh svg { display: block; } #tab-h2cstls:checked ~ .ex-menu label[for="tab-h2cstls"], #tab-h2bcl:checked ~ .ex-menu label[for="tab-h2bcl"], #tab-h3csstream:checked ~ .ex-menu label[for="tab-h3csstream"], +#tab-h3mtls:checked ~ .ex-menu label[for="tab-h3mtls"], #tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"], #tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"], #tab-h3buf:checked ~ .ex-menu label[for="tab-h3buf"], diff --git a/docs/index.html b/docs/index.html index 2931cb02..0d90f1be 100644 --- a/docs/index.html +++ b/docs/index.html @@ -51,6 +51,7 @@ + @@ -120,6 +121,7 @@ http/3 + @@ -1432,6 +1434,110 @@

HTTP/3 · request + response streamed

}

Both directions streamed, in pure C#. The request body is PULLED a chunk at a time through Http3Request.BodyReader, so a large upload is never held whole; the response is PUSHED through Http3ResponseWriter, one DATA frame per flush, so a large download is never built whole. /echo runs both at once - read a chunk, write a chunk - which is what a proxy does. Owning the framing is what makes the push side simple: a chunk is just [0x00][varint length][payload] handed to the QUIC stream, with no data-reader callback to answer and nothing to defer. carries a resume and a drain because nghttp3 pulls instead; this measures 1.32× its throughput on the same 8×1 KiB response.

+
+
+

HTTP/3 · mutual TLS

+ ioxide + ioxide.ngtcp2 + ioxide.http3 +
+
// dotnet add package ioxide
+// dotnet add package ioxide.ngtcp2
+// dotnet add package ioxide.http3
+//   PLAYGROUND_CLIENT_CA=ca.crt dotnet run -c Release --project Playground/Http3/MutualTls
+//   curl --http3 --cacert ca.crt --cert client.crt --key client.key https://localhost:8443/
+
+using System.Text;
+using ioxide;
+using ioxide.http3;
+using ioxide.ngtcp2;
+
+// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
+
+ushort quicPort = 8443;
+int    reactors = Environment.ProcessorCount;
+
+
+// The server's own certificate and key. Null generates a self-signed pair on first run.
+string? certOverride = null;
+string? keyOverride  = null;
+
+
+// The CA that client certificates are checked against. This is what turns mTLS ON - leave it null
+// and the server verifies nothing about the client, exactly as the other h3 samples do.
+string? clientCaPath = Environment.GetEnvironmentVariable("PLAYGROUND_CLIENT_CA");
+
+// Refuse a client that offers no certificate, during the handshake. Off, so a client without one
+// still connects and the handler decides what it may see - which is the more useful default when
+// only part of a site is protected.
+bool requireClientCertificate = Environment.GetEnvironmentVariable("PLAYGROUND_REQUIRE_CLIENT_CERT") == "1";
+
+// UDP receive slots per reactor: how many datagrams the ring can have outstanding at once.
+int udpRecvSlots = 16;
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+
+const string certPath = "cert.pem";   // any PEM pair
+const string keyPath  = "key.pem";
+
+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;
+}
+
+using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"],
+    clientCaPemPath: clientCaPath, requireClientCertificate: requireClientCertificate);
+
+var config = new ServerConfig
+{
+    ReactorCount = reactors,
+    Tcp = null,                                        // QUIC only
+    Udp = new UdpOptions { RecvSlots = udpRecvSlots },
+    Quic = new QuicOptions
+    {
+        Port = quicPort,
+        LocalCidLength = 8,
+        ConnectionFactory = engine.CreateFactory(),
+    },
+};
+
+var threads = new Thread[config.ReactorCount];
+
+for (int i = 0; i < threads.Length; i++)
+{
+    var reactor = new Reactor(i, config);
+
+    reactor.QuicHandle = (_, connection) =>
+        new Http3Connection(connection).RunAsync(_ =>
+        {
+            // Read HERE, per request - not where the connection is accepted. That callback runs
+            // before the handshake finishes, so there is no identity yet at that point.
+            string? peer = (connection as QuicEngineConnection)?.PeerSubject;
+
+            var response = new Http3Response
+            {
+                Body = Encoding.UTF8.GetBytes(peer is null
+                    ? "anonymous\n"
+                    : $"authenticated as {peer}\n"),
+            };
+            response.Headers.Add(("content-type"u8.ToArray(), "text/plain"u8.ToArray()));
+            return response;
+        });
+
+    threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
+    threads[i].Start();
+}
+
+Console.WriteLine($"[http3-mtls] {config.ReactorCount} reactors on :{quicPort}, "
+                + $"client CA {clientCaPath}, "
+                + $"client certificate {(requireClientCertificate ? "REQUIRED" : "optional")}");
+
+foreach (Thread thread in threads)
+{
+    thread.Join();
+}
+
+return 0;
+

The client proves who it is too. clientCaPemPath is what turns it on - the CA that client certificates are checked against - and the handler reads PeerSubject to find out WHICH client it got. That distinction is the point: a server that can only answer some valid certificate has a gate, where one that can name the peer has an identity to authorise against. Read it per REQUEST, not where the connection is accepted - that callback runs before the handshake finishes, so there is no identity yet. QUIC settles client authentication during the handshake, and RFC 9001 §4.4 forbids doing it afterwards, so this is a property of the whole CONNECTION: there is no asking for a certificate later because a request reached a protected route. That needs a second port. requireClientCertificate decides whether a client offering none is refused during the handshake or arrives unauthenticated for the handler to judge.

+

HTTP/3 · buffered (nghttp3)

diff --git a/ioxide.slnx b/ioxide.slnx index 00e83ad0..227c8bfe 100644 --- a/ioxide.slnx +++ b/ioxide.slnx @@ -70,6 +70,7 @@ + diff --git a/scripts/gen-docs-panes.py b/scripts/gen-docs-panes.py index 0082e700..c0f27dea 100644 --- a/scripts/gen-docs-panes.py +++ b/scripts/gen-docs-panes.py @@ -176,6 +176,22 @@ "the trade is that memory holds the whole body, which suits normal requests and not hostile " "uploads. Streamed runs you while the body is still arriving and credits the peer's " "flow-control window as you read, so memory is bound by one window instead."), + "h3mtls": ( + "Http3/MutualTls", "HTTP/3 · mutual TLS", "ioxide + ioxide.ngtcp2 + ioxide.http3", + ["PLAYGROUND_CLIENT_CA=ca.crt dotnet run -c Release --project Playground/Http3/MutualTls", + "curl --http3 --cacert ca.crt --cert client.crt --key client.key https://localhost:8443/"], + "The client proves who it is too. clientCaPemPath is what turns it on - the CA " + "that client certificates are checked against - and the handler reads " + "PeerSubject to find out WHICH client it got. That distinction is the point: a " + "server that can only answer some valid certificate has a gate, where one that can " + "name the peer has an identity to authorise against. " + "Read it per REQUEST, not where the connection is accepted - that callback runs before the " + "handshake finishes, so there is no identity yet. " + "QUIC settles client authentication during the handshake, and RFC 9001 §4.4 " + "forbids doing it afterwards, so this is a property of the whole CONNECTION: there is no " + "asking for a certificate later because a request reached a protected route. That needs a " + "second port. requireClientCertificate decides whether a client offering none " + "is refused during the handshake or arrives unauthenticated for the handler to judge."), "quicalpn": ( "Quic/Alpn", "QUIC · two protocols by ALPN", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3", ["curl --http3-only -k https://127.0.0.1:8443/"], diff --git a/src/clients/ioxide.file/ioxide.file.csproj b/src/clients/ioxide.file/ioxide.file.csproj index 0a98f9b0..9ec7989f 100644 --- a/src/clients/ioxide.file/ioxide.file.csproj +++ b/src/clients/ioxide.file/ioxide.file.csproj @@ -8,7 +8,7 @@ ioxide.file ioxide.file - 0.4.178 + 0.4.179 MDA2AV File serving for the ioxide io_uring runtime: immutable asset snapshots with baked responses, pooled positional ring reads, atomic reloads. MIT diff --git a/src/clients/ioxide.httpclient/ioxide.httpclient.csproj b/src/clients/ioxide.httpclient/ioxide.httpclient.csproj index c8d825b3..1f11f8d1 100644 --- a/src/clients/ioxide.httpclient/ioxide.httpclient.csproj +++ b/src/clients/ioxide.httpclient/ioxide.httpclient.csproj @@ -8,7 +8,7 @@ ioxide.httpclient ioxide.httpclient - 0.4.178 + 0.4.179 MDA2AV The ring-native HTTP client for the ioxide io_uring runtime: HTTP/1.1, HTTP/2 and HTTP/3 behind one API, with the protocol chosen per origin via Alt-Svc. One package - the h1 parser, a pure-C# HTTP/2 client on ioxide.http2's framing, the nghttp3 bridge, client-side TLS (SNI, ALPN and certificate verification) for https:// origins, and the negotiating client - sharing one set of message types. Every response resumes the awaiting handler inline on its own reactor thread. MIT diff --git a/src/clients/ioxide.pg/ioxide.pg.csproj b/src/clients/ioxide.pg/ioxide.pg.csproj index cfd3c7b5..8fee9cf9 100644 --- a/src/clients/ioxide.pg/ioxide.pg.csproj +++ b/src/clients/ioxide.pg/ioxide.pg.csproj @@ -8,7 +8,7 @@ ioxide.pg ioxide.pg - 0.4.178 + 0.4.179 MDA2AV Postgres driver for the ioxide io_uring runtime: pooled ring-native connections per reactor, ring-native connect and handshake, inline completion resume. MIT diff --git a/src/clients/ioxide.redis/ioxide.redis.csproj b/src/clients/ioxide.redis/ioxide.redis.csproj index ea45241f..86b1061a 100644 --- a/src/clients/ioxide.redis/ioxide.redis.csproj +++ b/src/clients/ioxide.redis/ioxide.redis.csproj @@ -8,7 +8,7 @@ ioxide.redis ioxide.redis - 0.4.178 + 0.4.179 MDA2AV Redis client for the ioxide io_uring runtime: pooled ring-native connections per reactor, full RESP2 protocol, a generic command API plus typed helpers (strings, keys, hashes, lists, sets, sorted sets, pub/sub, transactions, scripting), and pipelining. Inline completion resume. MIT diff --git a/src/ioxide/ioxide.csproj b/src/ioxide/ioxide.csproj index 1e750fcc..6b6538c6 100644 --- a/src/ioxide/ioxide.csproj +++ b/src/ioxide/ioxide.csproj @@ -8,7 +8,7 @@ ioxide ioxide - 0.4.178 + 0.4.179 MDA2AV A shared-nothing io_uring runtime for .NET: one ring per reactor thread, inline completions, zero native dependencies. The engine - reactor, connection, and the IRingHost client seam. Includes TLS termination: the OpenSSL handshake driven over the ring, then kernel TLS (kTLS) transmit offload, so handlers keep writing plaintext. TLS needs OpenSSL 3 and the Linux tls module; nothing else does, and neither is loaded unless you use it. MIT diff --git a/src/protocols/ioxide.http2/ioxide.http2.csproj b/src/protocols/ioxide.http2/ioxide.http2.csproj index 4a622707..a753db02 100644 --- a/src/protocols/ioxide.http2/ioxide.http2.csproj +++ b/src/protocols/ioxide.http2/ioxide.http2.csproj @@ -8,7 +8,7 @@ ioxide.http2 ioxide.http2 - 0.4.178 + 0.4.179 MDA2AV Pure-C# HTTP/2 for the ioxide io_uring runtime: framing, HPACK (static and dynamic tables, Huffman) and flow control, with zero native code. Serves h2c with prior knowledge and h2 over TLS by ALPN, buffered or streamed in either direction, and the same framing drives ioxide.httpclient's HTTP/2 client. MIT diff --git a/src/protocols/ioxide.http3/ioxide.http3.csproj b/src/protocols/ioxide.http3/ioxide.http3.csproj index c01517c7..f7fb8b5c 100644 --- a/src/protocols/ioxide.http3/ioxide.http3.csproj +++ b/src/protocols/ioxide.http3/ioxide.http3.csproj @@ -8,7 +8,7 @@ ioxide.http3 ioxide.http3 - 0.4.178 + 0.4.179 MDA2AV Pure C# HTTP/3 for the ioxide io_uring runtime: frame parsing, QPACK (static table + Huffman) and request dispatch with zero native dependencies. Rides any QuicConnection via its stream read surface - engine-agnostic, drop-in alternative to ioxide.nghttp3. MIT diff --git a/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj b/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj index c496b938..bc67668a 100644 --- a/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj +++ b/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj @@ -8,7 +8,7 @@ ioxide.nghttp2 ioxide.nghttp2 - 0.4.178 + 0.4.179 MDA2AV HTTP/2 for the ioxide io_uring runtime: framing, HPACK and flow control from nghttp2, statically linked behind a small shim with no external dependencies beyond libc. Serves HTTP/2 over any TcpConnection - h2c with prior knowledge, or h2 over TLS via ALPN - and backs the HTTP/2 client in ioxide.httpclient from the same session code. nghttp2 is sans-I/O, so ioxide keeps the ring and the loop. MIT diff --git a/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj b/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj index 49c82e18..a87adc94 100644 --- a/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj +++ b/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj @@ -8,7 +8,7 @@ ioxide.nghttp3 ioxide.nghttp3 - 0.4.178 + 0.4.179 MDA2AV HTTP/3 layer for the ioxide io_uring runtime: nghttp3 (H3 + QPACK) bundled as a single self-contained native library with no external dependencies. Rides any QuicConnection via its stream read surface - engine-agnostic, no ioxide.ngtcp2 dependency. MIT diff --git a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs index 2fe0d2a2..90b6ae67 100644 --- a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs +++ b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs @@ -24,6 +24,32 @@ public unsafe partial class QuicEngineConnection : QuicConnection private readonly QuicEngine? _engine; // server connections private readonly QuicClientEngine? _clientEngine; // client connections private nint _conn; // iq_conn*, null once closed + + /// + /// The verified client certificate's subject, or null when the peer offered none - which is + /// possible whenever the engine was built with a client CA but not + /// requireClientCertificate. Empty until the handshake completes. + /// + /// Read it to decide what an authenticated peer may do: a server that can only answer "some + /// valid certificate" has a gate rather than an identity. + /// + public string? PeerSubject + { + get + { + if (_conn == 0) + { + return null; + } + + Span buffer = stackalloc byte[256]; + fixed (byte* p = buffer) + { + nuint written = Ngtcp2.iq_conn_peer_subject(_conn, p, (nuint)buffer.Length); + return written == 0 ? null : System.Text.Encoding.UTF8.GetString(buffer[..(int)written]); + } + } + } private GCHandle _self; // stable void* user passed to the shim private bool _handshakeDone; private bool _closed; diff --git a/src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs b/src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs index a5a12f5a..c631e340 100644 --- a/src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs +++ b/src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs @@ -38,8 +38,22 @@ public sealed unsafe class QuicEngine : IDisposable /// none of them fails the handshake with no_application_protocol (RFC 9001 §8.1). Null/empty: /// accept whichever protocol the client offers first (the pre-H3 permissive behavior). /// + /// + /// PEM bundle that client certificates are validated against - mutual TLS. Null (the default) + /// leaves it off and the handshake is exactly what it was. + /// + /// QUIC settles client authentication during the handshake and RFC 9001 section 4.4 forbids + /// doing it afterwards, so this is a property of the whole connection: there is no asking for a + /// certificate later because a request happened to reach a protected route. + /// + /// + /// With a CA configured, whether a client offering no certificate is refused during the + /// handshake. False lets it connect unauthenticated and leaves the decision to the application, + /// which can read . + /// public QuicEngine(string certPemPath, string keyPemPath, uint cidLength = 8, string[]? alpn = null, - long maxSendRetentionBytes = 16L << 20) + long maxSendRetentionBytes = 16L << 20, + string? clientCaPemPath = null, bool requireClientCertificate = false) { CidLength = cidLength; // Clamp to a floor: the pump overshoots the high-water by at most one egress chunk (16 KiB), @@ -61,13 +75,15 @@ public QuicEngine(string certPemPath, string keyPemPath, uint cidLength = 8, str byte[] alpnWire = AlpnWire(alpn); fixed (byte* pAlpn = alpnWire) { - _engine = Ngtcp2.iq_engine_new(certPemPath, keyPemPath, (nuint)cidLength, - alpnWire.Length > 0 ? pAlpn : null, (nuint)alpnWire.Length, callbacks); + _engine = Ngtcp2.iq_engine_new_mtls(certPemPath, keyPemPath, (nuint)cidLength, + alpnWire.Length > 0 ? pAlpn : null, (nuint)alpnWire.Length, + clientCaPemPath, requireClientCertificate ? 1 : 0, callbacks); } if (_engine == 0) { throw new InvalidOperationException( - $"ioxide.ngtcp2: engine init failed (cert '{certPemPath}', key '{keyPemPath}')"); + $"ioxide.ngtcp2: engine init failed (cert '{certPemPath}', key '{keyPemPath}'" + + (clientCaPemPath is null ? ")" : $", client CA '{clientCaPemPath}')")); } } diff --git a/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs b/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs index 0ce9e738..f925ba5f 100644 --- a/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs +++ b/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs @@ -34,6 +34,22 @@ internal struct Callbacks [MarshalAs(UnmanagedType.LPUTF8Str)] string keyPemPath, nuint cidLen, byte* alpn, nuint alpnLen, Callbacks cbs); + /// + /// Engine with client-certificate verification. is the bundle + /// client certificates are validated against; null leaves mTLS off and the handshake unchanged. + /// decides whether a client offering none is refused + /// outright or merely arrives unauthenticated. + /// + [DllImport(Lib)] internal static extern nint iq_engine_new_mtls( + [MarshalAs(UnmanagedType.LPUTF8Str)] string certPemPath, + [MarshalAs(UnmanagedType.LPUTF8Str)] string keyPemPath, + nuint cidLen, byte* alpn, nuint alpnLen, + [MarshalAs(UnmanagedType.LPUTF8Str)] string? clientCaPemPath, int requireClientCert, + Callbacks cbs); + + /// The verified client identity, or 0 written when the peer offered none. + [DllImport(Lib)] internal static extern nuint iq_conn_peer_subject(nint conn, byte* outBuf, nuint outLen); + [DllImport(Lib)] internal static extern void iq_engine_free(nint engine); [DllImport(Lib)] internal static extern nint iq_accept( diff --git a/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj b/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj index 6d18be9e..31bdbefe 100644 --- a/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj +++ b/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj @@ -8,7 +8,7 @@ ioxide.ngtcp2 ioxide.ngtcp2 - 0.4.178 + 0.4.179 MDA2AV QUIC engine for the ioxide io_uring runtime: ngtcp2 + picotls bundled as a single self-contained native library (only system dependency: libcrypto.so.3 / OpenSSL 3.x). Plugs into the reactor's QUIC transport via QuicConnection. Server side; engine bindings in progress. MIT diff --git a/src/protocols/ioxide.ngtcp2/native/ioxide_ngtcp2_shim.c b/src/protocols/ioxide.ngtcp2/native/ioxide_ngtcp2_shim.c index d328b923..44c98217 100644 --- a/src/protocols/ioxide.ngtcp2/native/ioxide_ngtcp2_shim.c +++ b/src/protocols/ioxide.ngtcp2/native/ioxide_ngtcp2_shim.c @@ -58,6 +58,11 @@ typedef struct iq_engine { ptls_context_t ptls_ctx; ptls_openssl_sign_certificate_t sign_cert; ptls_on_client_hello_t on_client_hello; + /* mTLS. verify_cert does the actual path validation; peer_verify wraps it so the identity it + * proved can be recorded on the connection - authenticating a client you cannot then name is + * rarely what anyone wanted. Both are unused unless a client CA was configured. */ + ptls_openssl_verify_certificate_t verify_cert; + ptls_verify_certificate_t peer_verify; iq_callbacks cbs; size_t cidlen; uint8_t alpn[256]; /* allowlist, wire format (len-prefixed entries) */ @@ -85,6 +90,11 @@ typedef struct iq_conn { * overflow degrades gracefully to auto-credit (no backpressure for the extra stream). */ int64_t paced[32]; int paced_count; + + /* Set during the handshake when a client certificate was verified. The subject is captured + * there because picotls does not retain the peer chain afterwards. */ + char peer_subject[256]; + int peer_authenticated; } iq_conn; static int iq_paced_index(iq_conn *c, int64_t stream_id) @@ -110,6 +120,41 @@ static void iq_rand(uint8_t *dest, size_t destlen, const ngtcp2_rand_ctx *rand_c ptls_openssl_random_bytes(dest, destlen); } +/* Client certificate verification, wrapping picotls's OpenSSL verifier so the identity it just + * proved can be recorded. picotls does not keep the peer chain after the handshake, so if the + * subject is not taken here it is gone - and a server that authenticates a client without being + * able to say WHICH client has a gate, not an identity. */ +static int iq_verify_certificate(ptls_verify_certificate_t *self, ptls_t *tls, const char *server_name, + int (**verify_sign)(void *verify_ctx, uint16_t algo, ptls_iovec_t data, + ptls_iovec_t signature), + void **verify_data, ptls_iovec_t *certs, size_t num_certs) +{ + iq_engine *e = (iq_engine *)((char *)self - offsetof(iq_engine, peer_verify)); + + int rv = e->verify_cert.super.cb(&e->verify_cert.super, tls, server_name, verify_sign, verify_data, + certs, num_certs); + if (rv != 0 || num_certs == 0) { + return rv; /* refused, or nothing offered - nothing to record either way */ + } + + /* ngtcp2 parks the connection ref here, and conn_ref lives inside iq_conn. */ + void **slot = ptls_get_data_ptr(tls); + if (slot == NULL || *slot == NULL) { + return rv; + } + iq_conn *c = (iq_conn *)((char *)*slot - offsetof(iq_conn, conn_ref)); + + const uint8_t *der = certs[0].base; + X509 *leaf = d2i_X509(NULL, &der, (long)certs[0].len); + if (leaf != NULL) { + X509_NAME_oneline(X509_get_subject_name(leaf), c->peer_subject, (int)sizeof(c->peer_subject)); + c->peer_authenticated = 1; + X509_free(leaf); + } + + return rv; +} + /* ALPN. With an engine allowlist: pick the client's first offer that we accept, else fail the * handshake (RFC 9001 §8.1: no mutual protocol = no_application_protocol). Without one (empty * allowlist): accept whichever the client offered first - selection is the app's concern. */ @@ -142,6 +187,22 @@ static int iq_on_client_hello(ptls_on_client_hello_t *self, ptls_t *tls, return PTLS_ALERT_NO_APPLICATION_PROTOCOL; } +/* The verified client identity, or an empty string when the peer offered none. Returns the length + * written, or 0. */ +size_t iq_conn_peer_subject(iq_conn *c, char *out, size_t outlen) +{ + if (c == NULL || out == NULL || outlen == 0 || !c->peer_authenticated) { + return 0; + } + size_t n = strlen(c->peer_subject); + if (n >= outlen) { + n = outlen - 1; + } + memcpy(out, c->peer_subject, n); + out[n] = '\0'; + return n; +} + /* ---- ngtcp2 callbacks ------------------------------------------------------------------- */ static int iq_cb_handshake_completed(ngtcp2_conn *conn, void *user_data) @@ -268,9 +329,26 @@ static int iq_cb_get_new_connection_id_noreport(ngtcp2_conn *conn, ngtcp2_cid *c /* ---- engine ----------------------------------------------------------------------------- */ +iq_engine *iq_engine_new_mtls(const char *cert_pem_path, const char *key_pem_path, + size_t cidlen, const uint8_t *alpn, size_t alpn_len, + const char *client_ca_pem_path, int require_client_cert, + iq_callbacks cbs); + +/* The original five-argument form: no client certificates, exactly as before. */ iq_engine *iq_engine_new(const char *cert_pem_path, const char *key_pem_path, size_t cidlen, const uint8_t *alpn, size_t alpn_len, iq_callbacks cbs) +{ + return iq_engine_new_mtls(cert_pem_path, key_pem_path, cidlen, alpn, alpn_len, NULL, 0, cbs); +} + +/* With mTLS: client_ca_pem_path is the bundle client certificates are validated against, and + * require_client_cert decides whether a client that offers none is refused outright or merely + * unauthenticated. A NULL bundle leaves both off and the handshake is byte-for-byte what it was. */ +iq_engine *iq_engine_new_mtls(const char *cert_pem_path, const char *key_pem_path, + size_t cidlen, const uint8_t *alpn, size_t alpn_len, + const char *client_ca_pem_path, int require_client_cert, + iq_callbacks cbs) { iq_engine *e = calloc(1, sizeof(*e)); if (e == NULL) { @@ -327,6 +405,37 @@ iq_engine *iq_engine_new(const char *cert_pem_path, const char *key_pem_path, goto fail; } + /* AFTER configure_server_context, deliberately: it sets its own fields on the context, and + anything mTLS puts there first is not guaranteed to survive it. */ + if (client_ca_pem_path != NULL) { + X509_STORE *store = X509_STORE_new(); + if (store == NULL) { + fprintf(stderr, "[ioxide.ngtcp2] failed to allocate the client CA store\n"); + goto fail; + } + if (X509_STORE_load_locations(store, client_ca_pem_path, NULL) != 1) { + fprintf(stderr, "[ioxide.ngtcp2] failed to load client CA bundle from %s\n", client_ca_pem_path); + X509_STORE_free(store); + goto fail; + } + /* The store is owned by the verifier from here; freeing it separately would double-free. */ + if (ptls_openssl_init_verify_certificate(&e->verify_cert, store) != 0) { + fprintf(stderr, "[ioxide.ngtcp2] failed to init client certificate verification\n"); + X509_STORE_free(store); + goto fail; + } + X509_STORE_free(store); /* init took its own reference */ + + e->peer_verify.cb = iq_verify_certificate; + e->peer_verify.algos = e->verify_cert.super.algos; + e->ptls_ctx.verify_certificate = &e->peer_verify; + + /* Off: a client with no certificate is let through unauthenticated, and the handler decides. + * On: picotls refuses the handshake itself. */ + e->ptls_ctx.require_client_authentication = require_client_cert != 0; + } + + return e; fail: @@ -578,11 +687,23 @@ const char *iq_strerror(int liberr) typedef struct iq_client_engine { ptls_context_t ptls_ctx; + ptls_openssl_sign_certificate_t sign_cert; /* mTLS: signs with the client's own key */ iq_callbacks cbs; char alpn[64]; /* the protocol every connection from this engine offers */ } iq_client_engine; +iq_client_engine *iq_client_engine_new_mtls(const char *alpn, const char *cert_pem_path, + const char *key_pem_path, iq_callbacks cbs); + iq_client_engine *iq_client_engine_new(const char *alpn, iq_callbacks cbs) +{ + return iq_client_engine_new_mtls(alpn, NULL, NULL, cbs); +} + +/* A client that can prove who it is: cert_pem_path and key_pem_path are the certificate it presents + * when a server asks for one. Both NULL leaves the client exactly as it was, offering nothing. */ +iq_client_engine *iq_client_engine_new_mtls(const char *alpn, const char *cert_pem_path, + const char *key_pem_path, iq_callbacks cbs) { iq_client_engine *e = calloc(1, sizeof(*e)); if (e == NULL) { @@ -596,7 +717,39 @@ iq_client_engine *iq_client_engine_new(const char *alpn, iq_callbacks cbs) e->ptls_ctx.get_time = &ptls_get_time; e->ptls_ctx.key_exchanges = ptls_openssl_key_exchanges; e->ptls_ctx.cipher_suites = ptls_openssl_cipher_suites; - /* Test client: accept the server's self-signed cert unconditionally (verify_certificate NULL). */ + /* Accepts the server's certificate unconditionally (verify_certificate NULL) - this client + * exists to drive our own servers, not to authenticate them. */ + + if (cert_pem_path != NULL && key_pem_path != NULL) { + if (ptls_load_certificates(&e->ptls_ctx, cert_pem_path) != 0) { + fprintf(stderr, "[ioxide.ngtcp2] client: failed to load %s\n", cert_pem_path); + free(e); + return NULL; + } + + BIO *bio = BIO_new_file(key_pem_path, "r"); + if (bio == NULL) { + fprintf(stderr, "[ioxide.ngtcp2] client: failed to open key %s\n", key_pem_path); + free(e); + return NULL; + } + EVP_PKEY *pkey = PEM_read_bio_PrivateKey(bio, NULL, NULL, NULL); + BIO_free(bio); + if (pkey == NULL) { + fprintf(stderr, "[ioxide.ngtcp2] client: failed to parse key %s\n", key_pem_path); + free(e); + return NULL; + } + int rv = ptls_openssl_init_sign_certificate(&e->sign_cert, pkey); + EVP_PKEY_free(pkey); + if (rv != 0) { + fprintf(stderr, "[ioxide.ngtcp2] client: failed to init sign_certificate\n"); + free(e); + return NULL; + } + e->ptls_ctx.sign_certificate = &e->sign_cert.super; + } + if (ngtcp2_crypto_picotls_configure_client_context(&e->ptls_ctx) != 0) { free(e); return NULL; diff --git a/src/protocols/ioxide.ngtcp2/runtimes/linux-x64/native/libioxide_ngtcp2.so b/src/protocols/ioxide.ngtcp2/runtimes/linux-x64/native/libioxide_ngtcp2.so index 7096cec3..fc148cb3 100755 Binary files a/src/protocols/ioxide.ngtcp2/runtimes/linux-x64/native/libioxide_ngtcp2.so and b/src/protocols/ioxide.ngtcp2/runtimes/linux-x64/native/libioxide_ngtcp2.so differ diff --git a/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj b/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj index 7e1eafb5..a481eb94 100644 --- a/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj +++ b/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj @@ -8,7 +8,7 @@ ioxide.Kestrel ioxide.Kestrel - 0.4.178 + 0.4.179 MDA2AV ASP.NET Core Kestrel transport backed by the ioxide io_uring runtime: one reactor (ring) per core, SO_REUSEPORT load-balanced, with Kestrel's HTTP request loop pinned to the reactor thread. Drop-in via UseIoxide(). MIT diff --git a/tests/Ioxide.Tests.E2E/Program.cs b/tests/Ioxide.Tests.E2E/Program.cs index f9160c55..d7af83ba 100644 --- a/tests/Ioxide.Tests.E2E/Program.cs +++ b/tests/Ioxide.Tests.E2E/Program.cs @@ -18,6 +18,7 @@ private static int Main() QuicTests.Register(runner); QuicEngineTests.Register(runner); H3Tests.Register(runner); + MutualTlsTests.Register(runner); Http3Tests.Register(runner); return runner.Summary(); diff --git a/tests/Ioxide.Tests.E2E/Protocols/MutualTlsTests.cs b/tests/Ioxide.Tests.E2E/Protocols/MutualTlsTests.cs new file mode 100644 index 00000000..531895e4 --- /dev/null +++ b/tests/Ioxide.Tests.E2E/Protocols/MutualTlsTests.cs @@ -0,0 +1,121 @@ +using System.Text; +using ioxide; +using ioxide.nghttp3; +using ioxide.ngtcp2; + +namespace Ioxide.Tests; + +/// +/// Client certificates over QUIC. Three things have to hold together or the feature is theatre: a +/// certificate the server trusts gets in, one it does not is turned away, and the handler can say +/// WHICH client it got - a server that only knows "some valid certificate" has a gate, not an +/// identity. +/// +internal static class MutualTlsTests +{ + public static void Register(Runner runner) + { + runner.Test("mtls: a client with a trusted certificate connects, and is named", () => + { + (string ca, string serverCert, string serverKey, + string clientCert, string clientKey, _, _) = TestCert.EnsureMutualTls(); + + using var engine = new QuicEngine(serverCert, serverKey, cidLength: 8, alpn: ["h3"], + clientCaPemPath: ca, requireClientCertificate: true); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + // Read inside the REQUEST handler, not at accept: the connection callback fires + // before the handshake finishes, so the identity does not exist yet there. + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( + _ => new Nghttp3Response + { + Body = Encoding.ASCII.GetBytes((conn as QuicEngineConnection)?.PeerSubject ?? "anonymous"), + })); + + using var client = new H3TestClient("127.0.0.1", udpPort, clientCert, clientKey); + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + + (int status, string body) = client.Get("/", timeoutMs: 5000); + Assert.Equal(200, status); + Assert.True(body.Contains("alice"), $"the handler should see the client's subject, got: {body}"); + }); + + runner.Test("mtls: a client with no certificate is refused", () => + { + (string ca, string serverCert, string serverKey, _, _, _, _) = TestCert.EnsureMutualTls(); + + using var engine = new QuicEngine(serverCert, serverKey, cidLength: 8, alpn: ["h3"], + clientCaPemPath: ca, requireClientCertificate: true); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( + static _ => new Nghttp3Response { Body = "should never be reached"u8.ToArray() })); + + // The stock client offers nothing, which is the whole point of requiring one. + // + // Asserted on the REQUEST, not the handshake: in TLS 1.3 the client finishes its own + // side before it can learn the server rejected its certificate, so the client thinking + // it is established proves nothing. Being answered does. + using var client = new H3TestClient("127.0.0.1", udpPort); + client.Connect(); + client.CompleteHandshake(timeoutMs: 3000); + + (int status, _) = client.Get("/", timeoutMs: 3000); + Assert.True(status != 200, $"a client with no certificate was served anyway (status {status})"); + }); + + runner.Test("mtls: a certificate from another CA is refused", () => + { + (string ca, string serverCert, string serverKey, _, _, + string rogueCert, string rogueKey) = TestCert.EnsureMutualTls(); + + using var engine = new QuicEngine(serverCert, serverKey, cidLength: 8, alpn: ["h3"], + clientCaPemPath: ca, requireClientCertificate: true); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( + static _ => new Nghttp3Response { Body = "should never be reached"u8.ToArray() })); + + // Well-formed and correctly signed - by a CA this server has never heard of. + using var client = new H3TestClient("127.0.0.1", udpPort, rogueCert, rogueKey); + client.Connect(); + client.CompleteHandshake(timeoutMs: 3000); + + (int status, _) = client.Get("/", timeoutMs: 3000); + Assert.True(status != 200, $"an untrusted certificate was served anyway (status {status})"); + }); + + runner.Test("mtls: off by default - no client CA means no certificate is asked for", () => + { + // The regression guard for everyone not using mTLS: the handshake must be untouched. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + // Read inside the REQUEST handler, not at accept: the connection callback fires + // before the handshake finishes, so the identity does not exist yet there. + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( + _ => new Nghttp3Response + { + Body = Encoding.ASCII.GetBytes((conn as QuicEngineConnection)?.PeerSubject ?? "anonymous"), + })); + + using var client = new H3TestClient("127.0.0.1", udpPort); + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + + (int status, string body) = client.Get("/", timeoutMs: 5000); + Assert.Equal(200, status); + Assert.Equal("anonymous", body); + }); + } +} diff --git a/tests/Ioxide.Tests.Harness/H3TestClient.cs b/tests/Ioxide.Tests.Harness/H3TestClient.cs index d6b58aeb..f6516078 100644 --- a/tests/Ioxide.Tests.Harness/H3TestClient.cs +++ b/tests/Ioxide.Tests.Harness/H3TestClient.cs @@ -27,6 +27,16 @@ public sealed unsafe class H3TestClient : IDisposable private static ulong NowNs() => (ulong)(System.Diagnostics.Stopwatch.GetTimestamp() * (1_000_000_000.0 / System.Diagnostics.Stopwatch.Frequency)); + private readonly string? _certPath; + private readonly string? _keyPath; + + /// A client that presents a certificate when the server asks for one. + public H3TestClient(string host, int port, string certPath, string keyPath) : this(host, port) + { + _certPath = certPath; + _keyPath = keyPath; + } + public H3TestClient(string host, int port) { _udp = new UdpClient(); @@ -43,7 +53,9 @@ public void Connect() { OnStreamData = &OnQuicStreamData, }; - _clientEngine = iq_client_engine_new("h3", quicCbs); + _clientEngine = _certPath is null + ? iq_client_engine_new("h3", quicCbs) + : iq_client_engine_new_mtls("h3", _certPath, _keyPath!, quicCbs); Assert.True(_clientEngine != 0, "client engine init failed"); Span local = stackalloc byte[16]; @@ -385,6 +397,10 @@ private struct Ih3Callbacks [DllImport(QuicLib)] private static extern nint iq_client_engine_new([MarshalAs(UnmanagedType.LPUTF8Str)] string alpn, IqCallbacks cbs); [DllImport(QuicLib)] private static extern void iq_client_engine_free(nint e); [DllImport(QuicLib)] private static extern nint iq_client_connect(nint e, byte* localSa, nuint localLen, byte* remoteSa, nuint remoteLen, [MarshalAs(UnmanagedType.LPUTF8Str)] string serverName, [MarshalAs(UnmanagedType.LPUTF8Str)] string alpn, nuint scidLen, ulong ts, void* user, byte* scidOut); + [DllImport(QuicLib)] private static extern nint iq_client_engine_new_mtls( + [MarshalAs(UnmanagedType.LPUTF8Str)] string alpn, + [MarshalAs(UnmanagedType.LPUTF8Str)] string certPath, + [MarshalAs(UnmanagedType.LPUTF8Str)] string keyPath, IqCallbacks cbs); [DllImport(QuicLib)] private static extern long iq_client_open_bidi(nint conn); [DllImport(QuicLib)] private static extern long iq_conn_open_uni(nint conn); [DllImport(QuicLib)] private static extern nint iq_conn_write(nint conn, byte* dest, nuint destLen, long streamId, byte* data, nuint dataLen, int fin, long* pConsumed, ulong ts); diff --git a/tests/Ioxide.Tests.Harness/TestServer.cs b/tests/Ioxide.Tests.Harness/TestServer.cs index 93b258e8..b5152d52 100644 --- a/tests/Ioxide.Tests.Harness/TestServer.cs +++ b/tests/Ioxide.Tests.Harness/TestServer.cs @@ -760,6 +760,92 @@ public static bool Reachable(string host, int port) /// A throwaway self-signed cert for the TLS test, written to PEM (ioxide.tls wants paths). public static class TestCert { + /// + /// A CA, a server certificate and two client certificates - one the CA signed, one a DIFFERENT + /// CA signed. mTLS cannot be tested without all four: proving a good certificate is let in says + /// nothing unless a bad one is turned away. + /// + public static (string CaPath, string ServerCert, string ServerKey, + string ClientCert, string ClientKey, + string RogueCert, string RogueKey) EnsureMutualTls() + { + string dir = Path.Combine(Path.GetTempPath(), "ioxide-e2e-mtls"); + Directory.CreateDirectory(dir); + + string ca = Path.Combine(dir, "ca.crt"); + string serverCert = Path.Combine(dir, "server.crt"); + string serverKey = Path.Combine(dir, "server.key"); + string clientCert = Path.Combine(dir, "client.crt"); + string clientKey = Path.Combine(dir, "client.key"); + string rogueCert = Path.Combine(dir, "rogue.crt"); + string rogueKey = Path.Combine(dir, "rogue.key"); + + if (File.Exists(ca) && File.Exists(clientCert) && File.Exists(rogueCert)) + { + return (ca, serverCert, serverKey, clientCert, clientKey, rogueCert, rogueKey); + } + + // ONE window for the whole set. Taking UtcNow per certificate lets a leaf be issued a + // second after its CA and therefore outlive it, which .NET refuses outright - a flake that + // only fires when the two calls straddle a second boundary. + DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddDays(-1); + DateTimeOffset caNotAfter = notBefore.AddYears(2); + DateTimeOffset leafNotAfter = notBefore.AddYears(1); // strictly inside the CA's window + + using var caKey = System.Security.Cryptography.RSA.Create(2048); + var caRequest = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=ioxide test CA", caKey, System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + caRequest.CertificateExtensions.Add( + new System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension(true, false, 0, true)); + using var caCert = caRequest.CreateSelfSigned(notBefore, caNotAfter); + File.WriteAllText(ca, caCert.ExportCertificatePem()); + + Sign(caCert, "CN=localhost", serverCert, serverKey, server: true, notBefore, leafNotAfter); + Sign(caCert, "CN=alice", clientCert, clientKey, server: false, notBefore, leafNotAfter); + + // A second CA the server has never heard of, so its certificates must be refused. + using var rogueKeyPair = System.Security.Cryptography.RSA.Create(2048); + var rogueCa = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=rogue CA", rogueKeyPair, System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + rogueCa.CertificateExtensions.Add( + new System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension(true, false, 0, true)); + using var rogueCaCert = rogueCa.CreateSelfSigned(notBefore, caNotAfter); + Sign(rogueCaCert, "CN=mallory", rogueCert, rogueKey, server: false, notBefore, leafNotAfter); + + return (ca, serverCert, serverKey, clientCert, clientKey, rogueCert, rogueKey); + + static void Sign(System.Security.Cryptography.X509Certificates.X509Certificate2 issuer, + string subject, string certPath, string keyPath, bool server, + DateTimeOffset notBefore, DateTimeOffset notAfter) + { + using var key = System.Security.Cryptography.RSA.Create(2048); + var request = new System.Security.Cryptography.X509Certificates.CertificateRequest( + subject, key, System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + + if (server) + { + var names = new System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder(); + names.AddDnsName("localhost"); + names.AddIpAddress(System.Net.IPAddress.Loopback); + request.CertificateExtensions.Add(names.Build()); + } + + request.CertificateExtensions.Add( + new System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension( + [new System.Security.Cryptography.Oid(server ? "1.3.6.1.5.5.7.3.1" : "1.3.6.1.5.5.7.3.2")], false)); + + byte[] serial = new byte[8]; + System.Security.Cryptography.RandomNumberGenerator.Fill(serial); + using var signed = request.Create(issuer, notBefore, notAfter, serial); + + File.WriteAllText(certPath, signed.ExportCertificatePem()); + File.WriteAllText(keyPath, key.ExportPkcs8PrivateKeyPem()); + } + } + public static (string CertPath, string KeyPath) Ensure() { string dir = Path.Combine(Path.GetTempPath(), "ioxide-e2e-tls");