diff --git a/Playground/Http3/MutualTls/Program.cs b/Playground/Http3/MutualTls/Program.cs index 9e7669e2..c25daf6a 100644 --- a/Playground/Http3/MutualTls/Program.cs +++ b/Playground/Http3/MutualTls/Program.cs @@ -40,9 +40,13 @@ Env.OverrideCert(ref certOverride, ref keyOverride); -// The CA that client certificates are checked against. This is what turns mTLS ON - leave it null +// The CA that client certificates are checked against. This is what turns mTLS ON - leave both null // and the server verifies nothing about the client, exactly as the other h3 samples do. +// +// Either the bundle's path, or the bundle itself as PEM text for a host that keeps its CA in a +// secrets store rather than on disk. Set one, not both. string? clientCaPath = Environment.GetEnvironmentVariable("PLAYGROUND_CLIENT_CA"); +string? clientCaPem = Environment.GetEnvironmentVariable("PLAYGROUND_CLIENT_CA_PEM"); // 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 @@ -55,14 +59,16 @@ (string certPath, string keyPath) = QuicCert.Ensure(certOverride, keyOverride); -if (clientCaPath is null) +if (clientCaPath is null && clientCaPem is null) { - Console.Error.WriteLine("set PLAYGROUND_CLIENT_CA to a PEM bundle of the CA that signs your client certificates."); + Console.Error.WriteLine("set PLAYGROUND_CLIENT_CA to a PEM bundle of the CA that signs your client " + + "certificates, or PLAYGROUND_CLIENT_CA_PEM to that bundle as text."); return 1; } using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"], - clientCaPemPath: clientCaPath, requireClientCertificate: requireClientCertificate); + clientCaPemPath: clientCaPath, requireClientCertificate: requireClientCertificate, + clientCaPem: clientCaPem); var config = new ServerConfig { @@ -105,7 +111,7 @@ } Console.WriteLine($"[http3-mtls] {config.ReactorCount} reactors on :{quicPort}, " - + $"client CA {clientCaPath}, " + + $"client CA {clientCaPath ?? "from PEM text"}, " + $"client certificate {(requireClientCertificate ? "REQUIRED" : "optional")}"); foreach (Thread thread in threads) diff --git a/src/clients/ioxide.file/ioxide.file.csproj b/src/clients/ioxide.file/ioxide.file.csproj index 57ee02b5..711735f3 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.187 + 0.5.192 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 d96b701f..34e83a3c 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.187 + 0.5.192 MDA2AV The ring-native HTTP/1.1 client for the ioxide io_uring runtime - the upstream leg between a proxy and an origin. Connections are opened on the reactor thread that will use them, so a request never crosses a thread on its way out or back, and every response resumes the awaiting handler inline on its own reactor. Includes client-side TLS (SNI, ALPN, certificate verification and client certificates for mutual TLS) for https:// origins. Depends on ioxide core alone: no protocol package, no native asset. MIT diff --git a/src/clients/ioxide.pg/ioxide.pg.csproj b/src/clients/ioxide.pg/ioxide.pg.csproj index a35bc1b2..521dbf4f 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.187 + 0.5.192 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 04070ce1..47e840f3 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.187 + 0.5.192 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 835ae532..59c575e8 100644 --- a/src/ioxide/ioxide.csproj +++ b/src/ioxide/ioxide.csproj @@ -8,7 +8,7 @@ ioxide ioxide - 0.4.187 + 0.5.192 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 d4781400..0e4f9e0d 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.187 + 0.5.192 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. MIT diff --git a/src/protocols/ioxide.http3/ioxide.http3.csproj b/src/protocols/ioxide.http3/ioxide.http3.csproj index e00b5828..0e4ccc9a 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.187 + 0.5.192 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 43ee29ca..61be2112 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.187 + 0.5.192 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. 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 4d015a96..45ecb89a 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.187 + 0.5.192 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/Engine/QuicEngine.cs b/src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs index c631e340..d6e204c6 100644 --- a/src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs +++ b/src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs @@ -39,13 +39,19 @@ public sealed unsafe class QuicEngine : IDisposable /// 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. + /// PEM bundle that client certificates are validated against, as a path - mutual TLS. Null (the + /// default), with also null, 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. /// + /// + /// The same trust anchors as PEM text - the in-memory alternative to + /// , for a host that carries its CA bundle as data rather than + /// as a file. Set at most one of the two. + /// /// /// 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, @@ -53,8 +59,15 @@ public sealed unsafe class QuicEngine : IDisposable /// public QuicEngine(string certPemPath, string keyPemPath, uint cidLength = 8, string[]? alpn = null, long maxSendRetentionBytes = 16L << 20, - string? clientCaPemPath = null, bool requireClientCertificate = false) + string? clientCaPemPath = null, bool requireClientCertificate = false, + string? clientCaPem = null) { + if (clientCaPemPath is not null && clientCaPem is not null) + { + throw new ArgumentException( + "At most one client CA source: set clientCaPemPath or clientCaPem, not both.", nameof(clientCaPem)); + } + CidLength = cidLength; // Clamp to a floor: the pump overshoots the high-water by at most one egress chunk (16 KiB), // so a cap below that would wedge a response mid-flight. 256 KiB gives comfortable headroom. @@ -77,13 +90,14 @@ public QuicEngine(string certPemPath, string keyPemPath, uint cidLength = 8, str { _engine = Ngtcp2.iq_engine_new_mtls(certPemPath, keyPemPath, (nuint)cidLength, alpnWire.Length > 0 ? pAlpn : null, (nuint)alpnWire.Length, - clientCaPemPath, requireClientCertificate ? 1 : 0, callbacks); + clientCaPemPath, clientCaPem, requireClientCertificate ? 1 : 0, callbacks); } if (_engine == 0) { throw new InvalidOperationException( $"ioxide.ngtcp2: engine init failed (cert '{certPemPath}', key '{keyPemPath}'" - + (clientCaPemPath is null ? ")" : $", client CA '{clientCaPemPath}')")); + + (clientCaPemPath is not null ? $", client CA '{clientCaPemPath}')" + : clientCaPem is not null ? ", client CA from PEM text)" : ")")); } } diff --git a/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs b/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs index f925ba5f..c70aeeee 100644 --- a/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs +++ b/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs @@ -35,16 +35,19 @@ internal struct Callbacks 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. + /// Engine with client-certificate verification. Client certificates are validated against + /// , a bundle on disk, or , the + /// same bundle as PEM text - pass at most one. Both 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, + [MarshalAs(UnmanagedType.LPUTF8Str)] string? clientCaPemPath, + [MarshalAs(UnmanagedType.LPUTF8Str)] string? clientCaPem, + int requireClientCert, Callbacks cbs); /// The verified client identity, or 0 written when the peer offered none. diff --git a/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj b/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj index 2f182a1e..5eebad5d 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.187 + 0.5.192 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 44c98217..7600ebcb 100644 --- a/src/protocols/ioxide.ngtcp2/native/ioxide_ngtcp2_shim.c +++ b/src/protocols/ioxide.ngtcp2/native/ioxide_ngtcp2_shim.c @@ -33,6 +33,7 @@ #include #include #include +#include /* ---- callback table into C# ------------------------------------------------------------- */ @@ -331,24 +332,58 @@ static int iq_cb_get_new_connection_id_noreport(ngtcp2_conn *conn, ngtcp2_cid *c 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); + const char *client_ca_pem_path, const char *client_ca_pem, + 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); + return iq_engine_new_mtls(cert_pem_path, key_pem_path, cidlen, alpn, alpn_len, NULL, 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. */ +/* Trust anchors from PEM text rather than a file, for a host that carries its CA bundle as data. + * Returns how many certificates were added, 0 if the text held none usable. + * + * X509_STORE_load_locations cannot read memory, so the blocks are parsed here and added one at a + * time - the same anchors, reached the other way. A duplicate is not an error: add_cert refuses it + * and the store already holds one, so it counts. */ +static int iq_store_add_pem(X509_STORE *store, const char *pem) +{ + BIO *bio = BIO_new_mem_buf(pem, -1); + if (bio == NULL) { + return 0; + } + + int added = 0; + X509 *cert; + + while ((cert = PEM_read_bio_X509(bio, NULL, NULL, NULL)) != NULL) { + if (X509_STORE_add_cert(store, cert) == 1) { + added++; + } else if (ERR_GET_REASON(ERR_peek_last_error()) == X509_R_CERT_ALREADY_IN_HASH_TABLE) { + added++; + } + X509_free(cert); + } + + /* The loop ends by design on PEM_R_NO_START_LINE once the last block is consumed, and any + * duplicate above left its own. Neither is a failure, so the queue must not outlive this. */ + ERR_clear_error(); + + BIO_free(bio); + return added; +} + +/* With mTLS: the client certificates are validated against client_ca_pem_path, a bundle on disk, or + * client_ca_pem, the same bundle as PEM text - at most one, and require_client_cert decides whether + * a client that offers none is refused outright or merely arrives unauthenticated. Both NULL leaves + * mTLS 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) + const char *client_ca_pem_path, const char *client_ca_pem, + int require_client_cert, iq_callbacks cbs) { iq_engine *e = calloc(1, sizeof(*e)); if (e == NULL) { @@ -407,14 +442,20 @@ iq_engine *iq_engine_new_mtls(const char *cert_pem_path, const char *key_pem_pat /* 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) { + if (client_ca_pem_path != NULL || client_ca_pem != 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); + if (client_ca_pem_path != NULL) { + 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; + } + } else if (iq_store_add_pem(store, client_ca_pem) == 0) { + fprintf(stderr, "[ioxide.ngtcp2] the client CA PEM text held no usable certificate\n"); X509_STORE_free(store); goto fail; } 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 fc148cb3..c496b6ea 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 4aa1af96..613bc634 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.187 + 0.5.192 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/Protocols/MutualTlsTests.cs b/tests/Ioxide.Tests.E2E/Protocols/MutualTlsTests.cs index 531895e4..a4d48b60 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/MutualTlsTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/MutualTlsTests.cs @@ -92,6 +92,79 @@ public static void Register(Runner runner) Assert.True(status != 200, $"an untrusted certificate was served anyway (status {status})"); }); + runner.Test("mtls: the trust anchors can be PEM text rather than a file", () => + { + (string ca, string serverCert, string serverKey, + string clientCert, string clientKey, _, _) = TestCert.EnsureMutualTls(); + + // The same anchors as the first test, handed over as data instead of as a path - what a + // host keeping its bundle in a secrets store holds, without writing it out to read back. + using var engine = new QuicEngine(serverCert, serverKey, cidLength: 8, alpn: ["h3"], + requireClientCertificate: true, clientCaPem: File.ReadAllText(ca)); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + 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: PEM text anchors refuse a certificate from another CA", () => + { + // The other half of the one above: anchors read from text have to turn a stranger away + // as well as let the trusted one in. A store that verified nothing would pass that test + // just as happily, so admitting alice proves only half of it. + (string ca, string serverCert, string serverKey, _, _, + string rogueCert, string rogueKey) = TestCert.EnsureMutualTls(); + + using var engine = new QuicEngine(serverCert, serverKey, cidLength: 8, alpn: ["h3"], + requireClientCertificate: true, clientCaPem: File.ReadAllText(ca)); + + (_, 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() })); + + 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: naming both a CA file and CA text is refused", () => + { + (string ca, string serverCert, string serverKey, _, _, _, _) = TestCert.EnsureMutualTls(); + + // Two sources for one answer: silently picking either would make the other look applied. + bool refused = false; + + try + { + using var engine = new QuicEngine(serverCert, serverKey, cidLength: 8, alpn: ["h3"], + clientCaPemPath: ca, requireClientCertificate: true, clientCaPem: File.ReadAllText(ca)); + } + catch (ArgumentException) + { + refused = true; + } + + Assert.True(refused, "two client CA sources should be refused, not quietly resolved to one"); + }); + 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.