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");