diff --git a/src/ioxide/Tls/TlsService.cs b/src/ioxide/Tls/TlsService.cs index 283b33be..1c071216 100644 --- a/src/ioxide/Tls/TlsService.cs +++ b/src/ioxide/Tls/TlsService.cs @@ -205,6 +205,29 @@ private static byte[] Fold(string host) /// public static TlsService Start(Reactor reactor, TlsOptions options, bool register = true) { + // Scalars first, because the checks below reason about COMBINATIONS and a value outside its + // own domain makes that reasoning meaningless. Both of these resolve to something plausible + // rather than failing, which is the shape worth refusing: an undefined version maps to the + // TLS 1.2 floor through a not-Tls13 ternary, and a negative timeout disables the handshake + // sweep entirely because both readers guard on "> 0" - so the one bound on a peer that + // connects and then says nothing is silently off. No config binder validates an enum + // (Enum.Parse("3") succeeds), so neither value needs a cast to arrive. + if (!Enum.IsDefined(options.MinProtocolVersion)) + { + throw new ArgumentException( + $"MinProtocolVersion is {(int)options.MinProtocolVersion}, which is not one of " + + "Default, Tls12 or Tls13. Name the floor you want rather than leaving it to be " + + "resolved.", nameof(options)); + } + + if (options.HandshakeTimeoutMs < 0) + { + throw new ArgumentException( + $"HandshakeTimeoutMs is {options.HandshakeTimeoutMs}. Zero disables the handshake " + + "sweep; a negative value would disable it too, which is worth saying rather than " + + "arriving at by accident.", nameof(options)); + } + // RX alone cannot be programmed: the handoff shares the TCP_ULP that EnableTx installs. // Refuse loudly rather than silently serving the userspace path the caller opted out of. if (options.KernelRx && !options.KernelTx) diff --git a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs index 09b2ee69..dea5a25c 100644 --- a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs +++ b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs @@ -39,6 +39,11 @@ public unsafe partial class QuicEngineConnection : QuicConnection /// whose organisation is Acme\/CN=admin.internal satisfies a /// Contains("/CN=admin.internal") check while being a different principal. /// is the value to compare instead. + /// + /// Null also when the DN does not fit the 1024 bytes the shim records, which is a refusal + /// rather than an omission: a truncated DN is plausible, comparable, and can equal a DIFFERENT + /// principal's prefix, so no name is reported instead of a partial one. Nothing here ever + /// hands back a shortened identity. /// public string? PeerSubject { @@ -65,8 +70,11 @@ public string? PeerSubject /// with . /// /// Null when there was no validated certificate, when the subject carries no CN (legitimate: - /// modern certificates identify by subjectAltName), or when the CN is empty or contains an - /// embedded NUL, which is a name built to be read differently by different consumers. + /// modern certificates identify by subjectAltName), when the CN is empty or contains an + /// embedded NUL - a name built to be read differently by different consumers - or when it + /// exceeds the 256 bytes recorded for it, which is four times RFC 5280's ub-common-name of 64. + /// Every one of those is a refusal rather than an omission: the accessor never reports a name + /// it had to shorten, because a prefix can belong to someone else. /// public string? PeerCommonName { diff --git a/tests/Ioxide.Tests.E2E/Protocols/H3AlpnTests.cs b/tests/Ioxide.Tests.E2E/Protocols/H3AlpnTests.cs index f147d50a..b1e47327 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/H3AlpnTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/H3AlpnTests.cs @@ -1,22 +1,211 @@ using ioxide; +using ioxide.nghttp3; using ioxide.ngtcp2; namespace Ioxide.Tests; /// -/// ALPN as HTTP/3 requires it, and what the negotiated protocol reads back as. +/// ALPN as HTTP/3 requires it, and what the negotiated protocol reads back as. RFC 9001 section +/// 8.1 makes ALPN mandatory for QUIC (no mutual protocol = no_application_protocol, during the +/// handshake); RFC 9114 section 3.1 makes "h3" the token an HTTP/3 server may serve. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// Two behaviours in this area were examined and could NOT be driven from this suite, so they are +/// recorded here rather than half-tested: /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// - Server preference order with a multi-token offer (the shim's iq_on_client_hello walks the +/// allowlist on the outside, so the server's order decides, like the TCP side's +/// AlpnNegotiationTests pins). The shim's client entry points hand picotls exactly ONE token - +/// a single iovec, count = 1 - so no in-tree client can offer several protocols at once. +/// +/// - The negotiated token reading back as nothing when it exceeds the 64-byte read-back buffer +/// (iq_conn_get_alpn returns 0 when the token does not fit the buffer +/// QuicEngineConnection.HandshakeCompletedOnce hands it). A legal ALPN token may be 255 bytes, +/// but the shim's client stores its offer in a char[64] via snprintf, truncating it to 63 - so +/// the shortest token that would trip the server's cap cannot be offered from here. The 63-byte +/// test below pins the longest reachable token instead. /// internal static class H3AlpnTests { public static void Register(Runner runner) { + runner.Test("quic/alpn: control - a pinned engine serves an h3 offer and the handler reads back 'h3'", () => + { + // The control for every refusal below: the same engine shape, the same handler, the + // one offer an HTTP/3 server may accept - and it serves. Also the only place the + // SERVER-side NegotiatedProtocol value is asserted: the pure-C# stack consumes it in + // its backstop, but nothing else pins that the shim's read-back (iq_conn_get_alpn) + // surfaces the very token the client offered. + (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(), + quicHandle: (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( + _ => Nghttp3Response.Text($"alpn={conn.NegotiatedProtocol ?? "(none)"}"))); + + 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("/alpn", timeoutMs: 5000); + Assert.Equal(200, status); + Assert.Equal("alpn=h3", body); + }); + + runner.Test("quic/alpn: a pinned engine refuses a no-overlap offer during the handshake, with a close", () => + { + // RFC 9001 section 8.1: no mutual protocol fails the handshake with + // no_application_protocol. The engine-side allowlist is the real fix for serving h3 + // to clients that never claimed it, and every h3 test site now pins ["h3"] - but the + // refusal itself was one test deep and asserted only "not served". This pins the + // stronger half: the handshake never completes, and the refusal ARRIVES as a close. + // PeerClosed is the load-bearing assert - a server that silently dropped the + // connection would also fail CompleteHandshake, by timeout, and a hang is not a + // refusal (it is also not the alert RFC 9001 requires). + (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(), + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( + static _ => Nghttp3Response.Text("must never serve"))); + + using var client = new H3TestClient("127.0.0.1", udpPort) { Alpn = "echo" }; + client.Connect(); + bool done = client.CompleteHandshake(timeoutMs: 5000); + Assert.True(!done, "a pinned engine must not complete a handshake with no ALPN overlap"); + Assert.True(client.PeerClosed, + "the refusal must arrive as a close during the handshake - a timeout is a hang, not a refusal"); + }); + + runner.Test("quic/alpn: a pinned engine refuses a client that offered no ALPN at all", () => + { + // The other half of RFC 9001 section 8.1: a client that offers NOTHING. An empty Alpn + // makes the shim's client omit the extension entirely (it only hands picotls a list + // for a non-empty token), and a pinned server must treat that as no overlap - not as + // "nothing to check". The permissive engine's documented default is to confirm even + // this; pinning is what closes it, so the pinned refusal is the behaviour to hold. + (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(), + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( + static _ => Nghttp3Response.Text("must never serve"))); + + using var client = new H3TestClient("127.0.0.1", udpPort) { Alpn = "" }; + client.Connect(); + bool done = client.CompleteHandshake(timeoutMs: 5000); + Assert.True(!done, "RFC 9001 8.1: a pinned engine must refuse a client that offered no ALPN"); + Assert.True(client.PeerClosed, + "the refusal must arrive as a close during the handshake - a timeout is a hang, not a refusal"); + }); + + runner.Test("quic/alpn: a 63-byte token - the longest the harness can offer - reads back whole", () => + { + // The negotiated token reads back through a 64-byte buffer, and iq_conn_get_alpn + // answers 0 - "no protocol" - for anything that does not fit, so a shrunk buffer + // would not fail loudly: it would report a legal negotiated token as none at all. + // 63 bytes is the longest offer the harness client can make (its own char[64] + + // snprintf truncation - see the file remarks), which makes it the boundary this + // suite can hold: the whole token, not empty, not clipped. + // + // Recorded at handshake completion rather than through a served response, so this + // stays true even once the nghttp3 layer learns to refuse non-h3 connections. + string big = new string('a', 63); + var recorded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); // permissive: confirms the offer + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: (_, conn) => + { + ((QuicEngineConnection)conn).HandshakeCompleted = + () => recorded.TrySetResult(conn.NegotiatedProtocol); + return new Nghttp3Connection(conn).RunBufferedAsync( + static _ => Nghttp3Response.Text("ok")); + }); + + using var client = new H3TestClient("127.0.0.1", udpPort) { Alpn = big }; + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + + Assert.True(recorded.Task.Wait(5000), "the server never reported handshake completion"); + Assert.Equal(big, recorded.Task.Result); + }); + + runner.Pending("h3/nghttp3: a connection that did not negotiate h3 is not served", () => + { + // The mirror of Http3Tests' "a connection that did not negotiate h3 is not served", + // on the OTHER stack. The backstop landed only in the pure-C# layer + // (Http3Connection.RunCoreAsync checks NegotiatedProtocol once the control stream is + // up); Nghttp3Connection never reads it, so on an engine built without an allowlist - + // the constructor's documented default and its own doc example - it answers HTTP/3 on + // a connection that negotiated "echo", a protocol the client actually asked for and + // is entitled to believe it got. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); // permissive, on purpose + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( + static _ => Nghttp3Response.Text("served-by-nghttp3"))); + + using var client = new H3TestClient("127.0.0.1", udpPort) { Alpn = "echo" }; + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), + "the permissive engine should still complete the handshake - that is the point"); + + (int status, string body) = client.Get("/nope", timeoutMs: 3000); + Assert.True(status != 200, $"an h3 handler must not serve a non-h3 connection, got {status} '{body}'"); + }, "the ALPN backstop landed only in the pure-C# stack; Nghttp3Connection never reads " + + "NegotiatedProtocol, so a permissive engine's 'echo' connection is answered 200"); + + runner.Pending("quic/alpn: a non-ascii allow-list token must not admit a protocol nobody configured", () => + { + // QuicEngine.AlpnWire encodes each configured token with Encoding.ASCII, whose + // fallback substitutes '?' for anything non-ascii - so ["Ũ2"] goes on the wire as the + // allowlist entry "?2". Two consequences: the configured token itself can never + // negotiate (a client offering the actual bytes of "Ũ2" finds no match), and every + // non-ascii token collapses onto '?', so a client offering the literal "?2" is + // admitted, served, and reads back NegotiatedProtocol == "?2" - a protocol nobody + // configured. The TCP side's BuildAlpnWire has the same shape with a worse symptom + // (UTF-16 units cast to bytes turn "Ũ2" into the real "h2"); the fix on either side + // is to refuse a non-ascii token at construction, like the >255-byte one already is, + // or to encode it faithfully - both make this body pass. + (string certPath, string keyPath) = TestCert.Ensure(); + QuicEngine engine; + try + { + engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["Ũ2"]); + } + catch (ArgumentException) + { + return; // refused at configuration - the defect is gone + } + + using (engine) + { + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( + static _ => Nghttp3Response.Text("must never serve"))); + + using var client = new H3TestClient("127.0.0.1", udpPort) { Alpn = "?2" }; + client.Connect(); + Assert.True(!client.CompleteHandshake(timeoutMs: 5000), + "a client offering '?2' completed the handshake against an allow list of 'Ũ2'"); + } + }, "AlpnWire's ASCII '?' substitution puts \"?2\" on the wire for the configured \"Ũ2\", " + + "and a client offering the literal \"?2\" is admitted and served"); } } diff --git a/tests/Ioxide.Tests.E2E/Protocols/H3BodyTruncationTests.cs b/tests/Ioxide.Tests.E2E/Protocols/H3BodyTruncationTests.cs index aafab5cd..df3f65f7 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/H3BodyTruncationTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/H3BodyTruncationTests.cs @@ -1,23 +1,502 @@ +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; using ioxide; +using ioxide.http3; using ioxide.ngtcp2; namespace Ioxide.Tests; /// -/// A request body that stops early: reset mid-body, connection death mid-body, and whether a -/// handler can tell either from a body that simply ended. +/// A request body that stops early, on the pure-C# HTTP/3 stack: a stream that ends inside a DATA +/// frame it already sized, and a body shorter than the content-length that announced it. Both ask +/// the same question - can the handler that receives the bytes tell a body that ENDED from one +/// that was CUT - and the answer is currently no, on either overload. /// -/// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. -/// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. -/// internal static class H3BodyTruncationTests { public static void Register(Runner runner) { + runner.Test("control: a body that ends where its DATA frame said reads as a clean end", () => + { + // The half that is correct, and what makes the PEND below a finding rather than a + // guess: the same client, the same frames, the same handler, ten bytes promised and ten + // delivered. Its read ends cleanly, and it must - a body that finished is exactly what + // an empty chunk is for. The point is that the next test produces this same string. + Assert.Equal("ended after 10", StreamedBodyOutcome(promised: 10)); + }); + + runner.Test("http3: a body cut mid-frame kills the connection, and the reader signals an ordinary end", () => + { + // The same ten bytes, under a DATA frame header that promised a hundred: the stream + // ends 90 bytes inside a frame the client itself sized. No h3 client library will send + // that, so it is driven with hand-written frames over a raw QUIC client. + // + // The parser DOES see it: FeedRequest's fin branch finds the walk mid-payload and calls + // Fatal("stream ended mid-frame"), which closes the connection with H3_FRAME_ERROR. But + // the handler has been running since end-of-headers, has already been handed the ten + // bytes, and is parked in ReadAsync - and the teardown ends its sink through the same + // Drop/End the run loop uses for a clean fin. The next read returns an empty chunk, + // which Http3BodyReader documents as "end of body". A handler that commits what it + // received commits ten bytes as though they were the whole request. + string outcome = StreamedBodyOutcome(promised: 100); + + // Reviewed as a defect and kept. The protocol obligation is already met one layer up: + // a stream that ends mid-frame is a CONNECTION error of type H3_FRAME_ERROR (RFC 9114 + // 7.1), which Http3Connection raises, and the response is never sent - so no client is + // served partial data, which is what the rule is for. H3_REQUEST_INCOMPLETE covers a + // different case. And "an empty chunk means end of body" is not an ioxide.http3 + // shortcoming: all three body readers document it identically, the nghttp3 one included, + // so a truncation flag would have to land in all three or the two stacks would disagree + // about what a read returning nothing means. What is pinned is the contract as written. + Assert.True(outcome.StartsWith("ended", StringComparison.Ordinal), + $"the reader should signal an ordinary end, as all three body readers document, got: {outcome}"); + }); + + runner.Pending("http3: a body shorter than its content-length is refused, not served whole", () => + { + // The other way a body stops early, and the one a real client can produce: well-formed + // DATA frames whose payloads add up to less than the content-length the request + // announced. RFC 9114 4.1.2 makes that malformed - "a request or response is malformed + // if the value of a content-length header field does not equal the sum of the DATA frame + // payload lengths" - and malformed messages must be a stream error of H3_MESSAGE_ERROR. + // + // This is the BUFFERED overload, the one believed protected because it reassembles + // before dispatching. It is protected against a stream that ends mid-frame (that is + // Fatal, before _ready ever gets the id) and against nothing else: content-length is + // decoded into req.Headers and never compared with anything, so the handler is handed + // a ten-byte Body for a request that said one hundred and cannot tell the difference. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]); + + int served = -1; + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: (_, conn) => new Http3Connection(conn).RunAsync( + req => + { + Volatile.Write(ref served, req.Body.Length); + return Http3Response.Text($"got {req.Body.Length}"); + })); + + 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 text) = client.Request("POST", "/short", "0123456789"u8.ToArray(), + [("content-length", "100")], timeoutMs: 5000); + + Assert.True(status != 200, + $"the request announced content-length: 100 and delivered 10 body bytes; the server " + + $"answered {status} '{text}' and handed the handler a {Volatile.Read(ref served)}-byte " + + "body as if that were the whole request"); + }, + because: "nothing in ioxide.http3 reads the request's content-length: it is decoded into " + + "req.Headers and no path compares it with the bytes received, so RFC 9114 4.1.2's " + + "malformed-message rule is unenforced on both the buffered and the streaming overload"); + + runner.Test("control: a body that matches its content-length is served whole", () => + { + // What makes the refusal above mean something: the same server, the same client, the + // same POST of ten bytes - announced honestly - is served. Without this, "the server + // did not answer 200" would be satisfied by a server that answers nothing at all. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]); + + int served = -1; + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: (_, conn) => new Http3Connection(conn).RunAsync( + req => + { + Volatile.Write(ref served, req.Body.Length); + return Http3Response.Text($"got {req.Body.Length}"); + })); + + 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 text) = client.Request("POST", "/whole", "0123456789"u8.ToArray(), + [("content-length", "10")], timeoutMs: 5000); + + Assert.Equal(200, status); + Assert.Equal("got 10", text); + Assert.Equal(10, Volatile.Read(ref served)); + }); + } + + /// + /// Drive one streaming request whose DATA frame header promises + /// bytes and then delivers ten of them before the fin, and report what the handler's body read + /// told it: "ended after N" for the empty chunk, "raised X after N" if the read ever says the + /// body was cut. Promise ten and the request is honest; promise more and it is truncated, and + /// only the number differs between the two calls. + /// + /// + /// The writes are split and waited out because the ordering is the whole point: the handler + /// must be dispatched and reading BEFORE the stream ends. Sent as one datagram, Fatal runs + /// before dispatch and no handler ever sees the body - the safe case, which would make the + /// truncation test prove nothing. + /// + private static string StreamedBodyOutcome(long promised) + { + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]); + + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var readTen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: (_, conn) => new Http3Connection(conn).RunAsync( + async req => + { + started.TrySetResult(); + + long total = 0; + try + { + while (true) + { + ReadOnlyMemory chunk = await req.BodyReader!.ReadAsync(); + if (chunk.IsEmpty) + { + ended.TrySetResult($"ended after {total}"); + break; + } + total += chunk.Length; + if (total >= 10) + { + readTen.TrySetResult(); + } + } + } + catch (Exception e) + { + // The shape a fix would take: the read surface itself says it was cut. + ended.TrySetResult($"raised {e.GetType().Name} after {total}"); + } + + return Http3Response.Text($"got {total}"); + })); + + using var client = new RawH3Client("127.0.0.1", udpPort); + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + client.OpenRequestStream(); + + client.Write(RawH3Client.RequestHeaders("POST"), fin: false); + Assert.True(client.WaitFor(started.Task, timeoutMs: 5000), + "the streaming handler never ran, so nothing was reading a body at all"); + + client.Write(RawH3Client.DataFrameHeader(promised), fin: false); + client.Write("0123456789"u8, fin: false); + Assert.True(client.WaitFor(readTen.Task, timeoutMs: 5000), + "the handler never received the ten body bytes, so it has nothing it could commit"); + + client.Write(default, fin: true); + Assert.True(client.WaitFor(ended.Task, timeoutMs: 5000), + "the handler's body read never completed after the stream ended"); + + return ended.Task.Result; + } +} + +/// +/// A QUIC client that writes HTTP/3 frames by hand, over the ngtcp2 shim's client entry points - +/// the same ones and use. It exists because +/// the truncation under test is one no h3 library will produce: a DATA frame header that promises +/// a length the sender then does not deliver, with the writes split so the server dispatches the +/// handler before the stream is cut. Not production code, and it speaks only enough h3 to ask. +/// +/// +/// It never opens a control stream: the server parses one if it arrives but requires none, and a +/// SETTINGS exchange this test does not read would only be one more thing to go wrong. +/// +internal sealed unsafe class RawH3Client : IDisposable +{ + private readonly UdpClient _udp; + private readonly IPEndPoint _server; + private nint _engine; + private nint _conn; + private GCHandle _self; + private long _streamId = -1; + private bool _peerClosed; + + /// Whether the peer ended the connection - the engine reports every terminal state. + public bool PeerClosed => _peerClosed; + + private static ulong NowNs() => (ulong)(System.Diagnostics.Stopwatch.GetTimestamp() * + (1_000_000_000.0 / System.Diagnostics.Stopwatch.Frequency)); + + public RawH3Client(string host, int port) + { + _udp = new UdpClient(); + _udp.Client.ReceiveTimeout = 100; // short: every wait below is a pump loop + _server = new IPEndPoint(IPAddress.Parse(host), port); + _udp.Connect(_server); + } + + public void Connect() + { + _self = GCHandle.Alloc(this); + var callbacks = new IqCallbacks { OnStreamData = &OnStreamData }; + _engine = iq_client_engine_new_mtls("h3", null, null, callbacks); + Assert.True(_engine != 0, "client engine init failed"); + + Span local = stackalloc byte[16]; + Span remote = stackalloc byte[16]; + FillSockaddrIn(local, (ushort)((IPEndPoint)_udp.Client.LocalEndPoint!).Port, IPAddress.Loopback); + FillSockaddrIn(remote, (ushort)_server.Port, IPAddress.Loopback); + + fixed (byte* l = local) + fixed (byte* r = remote) + { + _conn = iq_client_connect(_engine, l, 16, r, 16, "localhost", "h3", + 16, NowNs(), (void*)GCHandle.ToIntPtr(_self), null); + } + Assert.True(_conn != 0, "client connect failed"); + } + + public bool CompleteHandshake(int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + FlushOut(); + if (iq_conn_is_established(_conn) != 0) + { + return true; + } + PumpIn(); + } + return false; + } + + public void OpenRequestStream() + { + _streamId = iq_client_open_bidi(_conn); + Assert.True(_streamId >= 0, "failed to open the request stream"); + } + + /// + /// A HEADERS frame for POST / (or GET /) on localhost: a QPACK field section of static-table + /// references plus one literal with a static name reference, which is the whole encoder surface + /// a capacity-0 advertisement leaves a client. + /// + public static byte[] RequestHeaders(string method) + { + byte methodIndex = method == "POST" ? (byte)20 : (byte)17; // static table 20/17 + byte[] authority = "localhost"u8.ToArray(); + + var fields = new List + { + 0x00, 0x00, // required insert count 0, delta base 0 + (byte)(0xC0 | methodIndex), // indexed, static: :method + 0xC0 | 23, // indexed, static: :scheme https + 0xC0 | 1, // indexed, static: :path / + 0x50, // literal, name = static 0 (:authority) + (byte)authority.Length, // value length, not Huffman-coded + }; + fields.AddRange(authority); + + var frame = new List(); + AppendVarint(frame, 0x1); // HEADERS + AppendVarint(frame, fields.Count); + frame.AddRange(fields); + return frame.ToArray(); + } + + /// A DATA frame header promising payload bytes to follow. + public static byte[] DataFrameHeader(long length) + { + var frame = new List(); + AppendVarint(frame, 0x0); // DATA + AppendVarint(frame, length); + return frame.ToArray(); + } + + private static void AppendVarint(List into, long value) + { + if (value < 64) + { + into.Add((byte)value); + return; + } + if (value < 16384) + { + into.Add((byte)(0x40 | (value >> 8))); + into.Add((byte)value); + return; + } + into.Add((byte)(0x80 | (value >> 24))); + into.Add((byte)(value >> 16)); + into.Add((byte)(value >> 8)); + into.Add((byte)value); + } + + /// + /// Write raw bytes, and/or a bare fin, on the request stream. Never drops a tail: while the + /// engine is blocked it pumps the wire, since the server's credits arrive as datagrams. + /// + public void Write(ReadOnlySpan data, bool fin) + { + long deadline = Environment.TickCount64 + 10_000; + int off = 0; + bool finPending = fin; + + while ((off < data.Length || finPending) && !_peerClosed) + { + Assert.True(Environment.TickCount64 < deadline, "client write stalled (window never reopened)"); + + long consumed; + nint n; + fixed (byte* dest = _sendScratch) + fixed (byte* src = data) + { + byte* ptr = off < data.Length ? src + off : null; + n = iq_conn_write(_conn, dest, (nuint)_sendScratch.Length, _streamId, + ptr, (nuint)(data.Length - off), finPending ? 1 : 0, &consumed, NowNs()); + } + + if ((int)n < 0) + { + FlushOut(); + PumpIn(); + continue; + } + + if (consumed > 0) + { + off += (int)consumed; + if (off >= data.Length) + { + finPending = false; // the fin rode out with the final bytes + } + } + else if (finPending && off >= data.Length && n > 0) + { + finPending = false; // bare-fin frame went out + } + + if (n > 0) + { + _udp.Send(_sendScratch, (int)n); + } + else if (consumed <= 0) + { + FlushOut(); + PumpIn(); + } + } } + + /// + /// Pump the wire until completes or the deadline passes. The signal + /// is set on the reactor, so this is a bound on a wait, not a measurement of one. + /// + public bool WaitFor(Task signal, int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline && !signal.IsCompleted) + { + FlushOut(); + PumpIn(); + } + return signal.IsCompleted; + } + + private readonly byte[] _sendScratch = new byte[1452]; + + private void FlushOut() + { + long consumed; + fixed (byte* dest = _sendScratch) + { + while (true) + { + nint n = iq_conn_write(_conn, dest, (nuint)_sendScratch.Length, -1, null, 0, 0, &consumed, NowNs()); + if (n <= 0) + { + break; + } + _udp.Send(_sendScratch, (int)n); + } + } + } + + private void PumpIn() + { + try + { + IPEndPoint? from = null; + byte[] packet = _udp.Receive(ref from); + fixed (byte* p = packet) + { + if (iq_conn_read(_conn, null, 0, p, (nuint)packet.Length, 0, NowNs()) != 0) + { + _peerClosed = true; + } + } + } + catch (SocketException) + { + // socket timeout - the caller loops + } + } + + private static void FillSockaddrIn(Span sa, ushort port, IPAddress addr) + { + sa.Clear(); + sa[0] = 2; // AF_INET + sa[2] = (byte)(port >> 8); + sa[3] = (byte)(port & 0xff); + addr.GetAddressBytes().CopyTo(sa[4..]); + } + + // The response is never read: this client asks a question the server answers by closing. + [UnmanagedCallersOnly] + private static void OnStreamData(void* user, long streamId, byte* data, nuint len, int fin) { } + + public void Dispose() + { + if (_conn != 0) iq_conn_free(_conn); + if (_engine != 0) iq_client_engine_free(_engine); + if (_self.IsAllocated) _self.Free(); + _udp.Dispose(); + } + + // --- shim entry points (test-only client surfaces) --- + + [StructLayout(LayoutKind.Sequential)] + private struct IqCallbacks + { + public delegate* unmanaged OnStreamData; + public delegate* unmanaged OnStreamClose; + public delegate* unmanaged OnHandshakeCompleted; + public delegate* unmanaged OnNewCid; + public delegate* unmanaged OnRetireCid; + public delegate* unmanaged OnStreamReset; + public delegate* unmanaged OnStreamStopSending; + public delegate* unmanaged OnAckedStreamData; + } + + private const string Lib = "ioxide_ngtcp2"; + [DllImport(Lib)] 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(Lib)] private static extern void iq_client_engine_free(nint e); + [DllImport(Lib)] 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(Lib)] private static extern long iq_client_open_bidi(nint conn); + [DllImport(Lib)] 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); + [DllImport(Lib)] private static extern int iq_conn_read(nint conn, void* remoteSa, nuint remoteLen, byte* pkt, nuint pktLen, byte ecn, ulong ts); + [DllImport(Lib)] private static extern int iq_conn_is_established(nint conn); + [DllImport(Lib)] private static extern void iq_conn_free(nint conn); } diff --git a/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs b/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs index 8483a031..bcfdc076 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs @@ -1,4 +1,9 @@ +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; using ioxide; +using ioxide.http3; +using ioxide.nghttp3; using ioxide.ngtcp2; namespace Ioxide.Tests; @@ -7,16 +12,615 @@ namespace Ioxide.Tests; /// The HTTP/3 error codes RFC 9114 section 8.1 defines, and which of them ever reach a peer. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// Two seams, deliberately paired: /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// The CODE seam - a subclass that records what the h3 layer asked +/// the transport to do. Close(code) is the single call every fatal path funnels into, so +/// asserting on it pins WHICH RFC 9114 code each protocol error carries; the fake's read surface +/// is the real base-class implementation, so the run loop under test is the production one. +/// +/// The WIRE seam - a raw ngtcp2 client (the shim's test-only client entry points, like +/// QuicTestClient) that hand-frames h3 bytes nghttp3 would refuse to send, and observes whether a +/// CONNECTION_CLOSE ever comes back. The shim exposes no way to read the RECEIVED close code, so +/// the wire tests prove the close happens promptly and the code seam proves which code it was. +/// +/// One site was examined and disproven rather than tested: Fatal("oversized frame header") +/// (and its control-stream twin) cannot fire. The 16-byte carry is exactly two maximal varints +/// (8 + 8), and a QUIC varint is at most 8 bytes, so by the time have == 16 both type and +/// length always parse. Dead defensive code, not a reachable behaviour. /// internal static class H3ErrorCodeTests { + // RFC 9114 section 8.1, plus RFC 9204 section 8.3 for the QPACK space. + private const ulong H3NoError = 0x0100; + private const ulong H3GeneralProtocolError = 0x0101; + private const ulong H3ClosedCriticalStream = 0x0104; + private const ulong H3FrameError = 0x0106; + private const ulong H3ExcessiveLoad = 0x0107; + private const ulong QpackDecompressionFailed = 0x0200; + + // A GET framed by hand: HEADERS(len 6), field-section prefix 00 00 (Required Insert Count 0, + // base 0), then static-table indexed lines :method GET (17), :scheme https (23), :path / (1), + // :authority "" (0). Static-only on purpose - the server advertises QPACK capacity 0. + private static readonly byte[] WellFormedGet = [0x01, 0x06, 0x00, 0x00, 0xD1, 0xD7, 0xC1, 0xC0]; + + // SETTINGS(len 0) - legal only on a control stream; on a request stream RFC 9114 section 7.2.4 + // demands H3_FRAME_ERROR. Two bytes of malformed h3 that both stacks must refuse. + private static readonly byte[] SettingsOnRequestStream = [0x04, 0x00]; + public static void Register(Runner runner) { + RegisterCodeSeam(runner); + RegisterCriticalStream(runner); + RegisterWire(runner); + RegisterNghttp3(runner); + } + + // --- the code seam: which RFC 9114 code each pure-C# Fatal site closes with ----------------- + + private static void RegisterCodeSeam(Runner runner) + { + runner.Test("http3/codes: control - a hand-framed GET is served through the recording transport", () => + { + var quic = new RecordingQuic(); + Task run = StartPure(quic, out Served served, closeTransport: true, (0, WellFormedGet, true)); + + Assert.True(run.IsCompleted, "the run loop should complete inline once the transport closes"); + Assert.Equal(1, served.Count); + Assert.True(quic.ClosedWith is null, + $"a served connection must not be closed, got 0x{quic.ClosedWith:x}"); + Assert.True(quic.Sent.Any(s => s.StreamId == 0 && s.Fin && s.Bytes.Length > 0 && s.Bytes[0] == 0x01), + "the response (a HEADERS frame, fin) should have gone out on the request stream"); + }); + + runner.Test("http3/codes: DATA before HEADERS closes with H3_FRAME_ERROR", () => + { + var quic = new RecordingQuic(); + Task run = StartPure(quic, out Served served, closeTransport: false, + (0, new byte[] { 0x00, 0x03, 0x61, 0x62, 0x63 }, false)); // DATA(len 3) as the first frame + + Assert.True(run.IsCompleted, "a fatal protocol error must end the run loop"); + Assert.Equal(0, served.Count); + AssertClosedWith(quic, H3FrameError, "H3_FRAME_ERROR"); + }); + + runner.Test("http3/codes: an empty HEADERS frame closes with H3_FRAME_ERROR", () => + { + var quic = new RecordingQuic(); + Task run = StartPure(quic, out Served served, closeTransport: false, + (0, new byte[] { 0x01, 0x00 }, false)); // HEADERS(len 0) + + Assert.True(run.IsCompleted, "a fatal protocol error must end the run loop"); + Assert.Equal(0, served.Count); + AssertClosedWith(quic, H3FrameError, "H3_FRAME_ERROR"); + }); + + runner.Test("http3/codes: SETTINGS on a request stream closes with H3_FRAME_ERROR", () => + { + var quic = new RecordingQuic(); + Task run = StartPure(quic, out Served served, closeTransport: false, + (0, SettingsOnRequestStream, false)); + + Assert.True(run.IsCompleted, "a fatal protocol error must end the run loop"); + Assert.Equal(0, served.Count); + AssertClosedWith(quic, H3FrameError, "H3_FRAME_ERROR"); + }); + + runner.Test("http3/codes: a stream ending mid-frame closes with H3_FRAME_ERROR", () => + { + var quic = new RecordingQuic(); + Task run = StartPure(quic, out Served served, closeTransport: false, + (0, new byte[] { 0x01, 0x0A, 0x01, 0x02, 0x03 }, true)); // HEADERS claims 10, fin after 3 + + Assert.True(run.IsCompleted, "a fatal protocol error must end the run loop"); + Assert.Equal(0, served.Count); + AssertClosedWith(quic, H3FrameError, "H3_FRAME_ERROR"); + }); + + runner.Test("http3/codes: a header section past the limit closes with H3_EXCESSIVE_LOAD", () => + { + var quic = new RecordingQuic(); + Task run = StartPure(quic, out Served served, closeTransport: false, + (0, new byte[] { 0x01, 0x80, 0x01, 0x00, 0x01 }, false)); // HEADERS(len 65537) > 64 KiB + + Assert.True(run.IsCompleted, "a fatal protocol error must end the run loop"); + Assert.Equal(0, served.Count); + AssertClosedWith(quic, H3ExcessiveLoad, "H3_EXCESSIVE_LOAD"); + }); + + runner.Test("http3/codes: a dynamic-table reference closes with QPACK_DECOMPRESSION_FAILED", () => + { + // Prefix 00 00, then 0x80: an Indexed Field Line with T=0 - a dynamic-table reference, + // against a decoder that advertised capacity 0. RFC 9204 section 3.2.5 territory. + var quic = new RecordingQuic(); + Task run = StartPure(quic, out Served served, closeTransport: false, + (0, new byte[] { 0x01, 0x03, 0x00, 0x00, 0x80 }, false)); + + Assert.True(run.IsCompleted, "a fatal protocol error must end the run loop"); + Assert.Equal(0, served.Count); + AssertClosedWith(quic, QpackDecompressionFailed, "QPACK_DECOMPRESSION_FAILED"); + }); + + runner.Test("http3/codes: a non-h3 ALPN closes with H3_GENERAL_PROTOCOL_ERROR", () => + { + var quic = new RecordingQuic(alpn: "echo"); + Task run = StartPure(quic, out Served served, closeTransport: true); + + Assert.True(run.IsCompleted, "the ALPN backstop must end the run loop"); + Assert.Equal(0, served.Count); + AssertClosedWith(quic, H3GeneralProtocolError, "H3_GENERAL_PROTOCOL_ERROR"); + }); + } + + // --- RFC 9114 section 6.2.1: critical streams ------------------------------------------------ + + private static void RegisterCriticalStream(Runner runner) + { + runner.Test("http3/codes: control - a client control stream is parsed and requests still serve", () => + { + // The premise for the Pending below: the uni-stream feed path genuinely runs (type + // varint, SETTINGS walk) and an OPEN control stream is not an error. The Pending + // differs from this rig by exactly one bit - the fin. + var quic = new RecordingQuic(); + Task run = StartPure(quic, out Served served, closeTransport: true, + (2, new byte[] { 0x00, 0x04, 0x00 }, false), // stream type 0x00 (control) + SETTINGS(len 0) + (0, WellFormedGet, true)); + + Assert.True(run.IsCompleted, "the run loop should complete inline once the transport closes"); + Assert.Equal(1, served.Count); + Assert.True(quic.ClosedWith is null, "an open control stream is not an error"); + }); + + runner.Pending("http3/codes: closing the peer's control stream is a connection error H3_CLOSED_CRITICAL_STREAM", () => + { + // RFC 9114 section 6.2.1: "If either control stream is closed at any point, this MUST + // be treated as a connection error of type H3_CLOSED_CRITICAL_STREAM." + var quic = new RecordingQuic(); + Task run = StartPure(quic, out _, closeTransport: true, + (2, new byte[] { 0x00, 0x04, 0x00 }, false), // the same control stream as above... + (2, Array.Empty(), true)); // ...now fin'd - a critical stream closed + + Assert.True(run.IsCompleted, "the run loop should have ended"); + AssertClosedWith(quic, H3ClosedCriticalStream, "H3_CLOSED_CRITICAL_STREAM"); + }, "RFC 9114 6.2.1 - FeedUni swallows the fin (drops the stream record, keeps serving), and " + + "a RESET of the control stream is swallowed the same way by Feed's lifecycle branch"); + } + + // --- the wire seam: does the close actually reach a real peer, and when ---------------------- + + private static void RegisterWire(Runner runner) + { + runner.Test("http3/wire: control - a hand-framed GET over real QUIC is served and the connection stays open", () => + { + (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(), + quicHandle: static (_, conn) => new Http3Connection(conn).RunAsync( + static _ => Http3Response.Text("raw served"))); + + using var peer = new RawH3Peer("127.0.0.1", udpPort); + peer.Connect(); + Assert.True(peer.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + + long sid = peer.OpenBidi(); + peer.SendRaw(sid, WellFormedGet, fin: true); + + Assert.True(peer.PumpUntilStreamFin(sid, timeoutMs: 5000), + "no response arrived for a well-formed hand-framed GET - the wire rig is not serving"); + byte[] response = peer.ReceivedOn(sid); + Assert.True(response.Length > 0 && response[0] == 0x01, + "the response should begin with a HEADERS frame"); + + peer.PumpFor(1500); + Assert.True(!peer.Closed, "a served connection must not be closed out from under the client"); + }); + + runner.Test("http3/wire: a malformed request stream draws a CONNECTION_CLOSE promptly, not at the idle sweep", () => + { + // Until recently a protocol error only set a flag: nothing went on the wire, and the + // connection stayed registered and routable until the transport's 60 s idle sweep. + // The control above proves this exact rig serves; the only difference here is the two + // malformed bytes. 15 s is a generous bound for "now" and far from the sweep. + (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(), + quicHandle: static (_, conn) => new Http3Connection(conn).RunAsync( + static _ => Http3Response.Text("unreached"))); + + using var peer = new RawH3Peer("127.0.0.1", udpPort); + peer.Connect(); + Assert.True(peer.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + + long sid = peer.OpenBidi(); + peer.SendRaw(sid, SettingsOnRequestStream, fin: false); // and hold the stream open + + Assert.True(peer.PumpUntilClosed(timeoutMs: 15_000), + "a fatal h3 protocol error must CONNECTION_CLOSE the peer promptly - " + + "nothing arrived, which is the old sit-until-the-idle-sweep behaviour"); + }); + } + + // --- the other stack: ioxide.nghttp3 --------------------------------------------------------- + + private static void RegisterNghttp3(Runner runner) + { + runner.Test("h3/codes: nghttp3 - a malformed request stream ends the connection handler", () => + { + // The premise for the Pending below: nghttp3 does reject SETTINGS on a request stream + // (ih3_read_stream goes negative, _protocolFailed trips, the run loop exits). + var quic = new RecordingQuic(); + Assert.True(quic.EnqueueStreamData(0, SettingsOnRequestStream, false), "the recv ring rejected the item"); + + int served = 0; + Task run = new Nghttp3Connection(quic).RunBufferedAsync(req => + { + served++; + return Nghttp3Response.Text("ok"); + }); + + Assert.True(run.IsCompleted, "nghttp3 should reject SETTINGS on a request stream and end the run"); + Assert.Equal(0, served); + }); + + runner.Pending("h3/codes: nghttp3 - a protocol error tells the peer why", () => + { + var quic = new RecordingQuic(); + Assert.True(quic.EnqueueStreamData(0, SettingsOnRequestStream, false), "the recv ring rejected the item"); + + Task run = new Nghttp3Connection(quic).RunBufferedAsync(static _ => Nghttp3Response.Text("ok")); + Assert.True(run.IsCompleted, "premise: nghttp3 rejected the stream and the run ended"); + + Assert.True(quic.ClosedWith is ulong code && code != H3NoError, + "the peer is entitled to an h3 error code on a CONNECTION_CLOSE; " + + (quic.ClosedWith is null + ? "Close was never called - the connection sits registered until the idle sweep" + : $"it closed with 0x{quic.ClosedWith:x}")); + }, "the defect the pure-C# stack just fixed, still live here: PushToEngine sets _protocolFailed " + + "and the run loop exits without ever calling QuicConnection.Close, so no code reaches the " + + "peer and the connection stays routable until the transport's 60 s idle sweep"); + } + + // --- helpers --------------------------------------------------------------------------------- + + private sealed class Served { public int Count; } + + /// + /// Enqueue the items, optionally close the transport (the wake for rigs that never go fatal), + /// and run the pure-C# h3 layer over the recording fake. Every await completes synchronously, + /// so for a fatal or closed transport the returned task is already finished - asserting + /// IsCompleted is the proof the path under test actually ran. + /// + private static Task StartPure(RecordingQuic quic, out Served served, bool closeTransport, + params (long Sid, byte[] Bytes, bool Fin)[] items) + { + foreach ((long sid, byte[] bytes, bool fin) in items) + { + Assert.True(quic.EnqueueStreamData(sid, bytes, fin), "the recv ring rejected a test item"); + } + if (closeTransport) + { + quic.MarkClosed(); + } + + Served count = new(); + served = count; + return new Http3Connection(quic).RunAsync(req => + { + count.Count++; + return Http3Response.Text("ok"); + }); + } + + private static void AssertClosedWith(RecordingQuic quic, ulong code, string name) + => Assert.True(quic.ClosedWith == code, + $"expected the connection to close with {name} (0x{code:x}), got " + + (quic.ClosedWith is ulong got ? $"0x{got:x}" : "no Close at all")); + + /// + /// A that records what the h3 layer asks of its transport - + /// SendStream payloads and, above all, the application error code passed to Close. The read + /// surface (EnqueueStreamData / MarkClosed / ReadAsync) is the real base implementation, so + /// the run loop under test is the production one; only the engine underneath is absent. + /// + private sealed class RecordingQuic : QuicConnection + { + public readonly List<(long StreamId, byte[] Bytes, bool Fin)> Sent = []; + public ulong? ClosedWith; + public int CloseCalls; + private long _nextUni = 3; // server-initiated uni ids: 3, 7, 11, ... + + public RecordingQuic(string? alpn = "h3") + { + NegotiatedProtocol = alpn; + } + + public override void OnDatagram(ReadOnlySpan payload, byte tos) { } + public override long GetNextTimeout(long nowMs) => long.MaxValue; + public override void OnTimer(long nowMs) { } + public override void OnEvicted(QuicEvictReason reason) { } + + public override void SendStream(long streamId, ReadOnlySpan data, bool fin) + => Sent.Add((streamId, data.ToArray(), fin)); + + public override long OpenUniStream() + { + long id = _nextUni; + _nextUni += 4; + return id; + } + + public override void Close(ulong applicationErrorCode) + { + CloseCalls++; + ClosedWith ??= applicationErrorCode; + MarkClosed(); // what the real engine does: parked reads resume with a closed snapshot + } + } + + /// + /// A raw ngtcp2 client over a real loopback UDP socket (the shim's test-only client entry + /// points, like QuicTestClient) that hand-frames h3 bytes nghttp3 would refuse to send. The + /// shim never surfaces the code inside a RECEIVED CONNECTION_CLOSE, so this observes THAT the + /// peer was told and when; the code itself is pinned at the Close seam above. + /// + private sealed unsafe class RawH3Peer : IDisposable + { + private readonly UdpClient _udp; + private readonly IPEndPoint _server; + private nint _engine; + private nint _conn; + private GCHandle _self; + private readonly byte[] _scratch = new byte[1452]; + + private readonly Dictionary> _received = new(); + private readonly HashSet _finished = []; + + /// The engine reported the connection over - how a server's CONNECTION_CLOSE lands here. + public bool Closed { get; private set; } + + private static ulong NowNs() => (ulong)(System.Diagnostics.Stopwatch.GetTimestamp() * + (1_000_000_000.0 / System.Diagnostics.Stopwatch.Frequency)); + + public RawH3Peer(string host, int port) + { + _udp = new UdpClient(); + _udp.Client.ReceiveTimeout = 250; + _server = new IPEndPoint(IPAddress.Parse(host), port); + _udp.Connect(_server); + } + + public void Connect() + { + _self = GCHandle.Alloc(this); + var cbs = new IqCallbacks { OnStreamData = &OnStreamData }; + _engine = iq_client_engine_new_mtls("h3", null, null, cbs); + Assert.True(_engine != 0, "client engine init failed"); + + Span local = stackalloc byte[16]; + Span remote = stackalloc byte[16]; + FillSockaddrIn(local, (ushort)((IPEndPoint)_udp.Client.LocalEndPoint!).Port, IPAddress.Loopback); + FillSockaddrIn(remote, (ushort)_server.Port, IPAddress.Loopback); + + fixed (byte* l = local) + fixed (byte* r = remote) + { + _conn = iq_client_connect(_engine, l, 16, r, 16, "localhost", "h3", + 16, NowNs(), (void*)GCHandle.ToIntPtr(_self), null); + } + Assert.True(_conn != 0, "client connect failed"); + } + + public bool CompleteHandshake(int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline && !Closed) + { + FlushOut(); + if (iq_conn_is_established(_conn) != 0) + { + return true; + } + PumpIn(); + } + return false; + } + + public long OpenBidi() + { + long sid = iq_client_open_bidi(_conn); + Assert.True(sid >= 0, "failed to open a client bidi stream"); + return sid; + } + + /// Push raw bytes - h3-framed by the TEST, not by nghttp3 - onto one stream. + public void SendRaw(long sid, ReadOnlySpan data, bool fin) + { + long deadline = Environment.TickCount64 + 10_000; + int off = 0; + bool finPending = fin; + + while ((off < data.Length || finPending) && !Closed) + { + Assert.True(Environment.TickCount64 < deadline, "raw client write stalled"); + + long consumed; + nint n; + fixed (byte* dest = _scratch) + fixed (byte* src = data) + { + byte* ptr = off < data.Length ? src + off : null; + n = iq_conn_write(_conn, dest, (nuint)_scratch.Length, sid, + ptr, (nuint)(data.Length - off), finPending ? 1 : 0, &consumed, NowNs()); + } + + if ((int)n < 0) + { + FlushOut(); + PumpIn(); + continue; + } + if (consumed > 0) + { + off += (int)consumed; + if (off >= data.Length) + { + finPending = false; + } + } + else if (finPending && off >= data.Length && n > 0) + { + finPending = false; + } + + if (n > 0) + { + _udp.Send(_scratch, (int)n); + } + else if (consumed <= 0) + { + FlushOut(); + PumpIn(); + } + } + } + + public bool PumpUntilClosed(int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline && !Closed) + { + FlushOut(); + PumpIn(); + } + return Closed; + } + + public bool PumpUntilStreamFin(long sid, int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline && !Closed && !_finished.Contains(sid)) + { + FlushOut(); + PumpIn(); + } + return _finished.Contains(sid); + } + + public void PumpFor(int ms) + { + long deadline = Environment.TickCount64 + ms; + while (Environment.TickCount64 < deadline && !Closed) + { + FlushOut(); + PumpIn(); + } + } + + public byte[] ReceivedOn(long sid) + => _received.TryGetValue(sid, out List? bytes) ? bytes.ToArray() : []; + + private void FlushOut() + { + long consumed; + fixed (byte* dest = _scratch) + { + while (true) + { + nint n = iq_conn_write(_conn, dest, (nuint)_scratch.Length, -1, null, 0, 0, &consumed, NowNs()); + if (n <= 0) + { + break; + } + _udp.Send(_scratch, (int)n); + } + } + } + + private void PumpIn() + { + try + { + IPEndPoint? from = null; + byte[] pkt = _udp.Receive(ref from); + fixed (byte* p = pkt) + { + // Nonzero covers draining/closing and every protocol error: the connection is + // finished, which after a server-side abort is the CONNECTION_CLOSE landing. + if (iq_conn_read(_conn, null, 0, p, (nuint)pkt.Length, 0, NowNs()) != 0) + { + Closed = true; + } + } + } + catch (SocketException) + { + // receive timeout - the caller's loop decides whether to keep pumping + } + } + + private static RawH3Peer From(void* user) + => (RawH3Peer)GCHandle.FromIntPtr((nint)user).Target!; + + [UnmanagedCallersOnly] + private static void OnStreamData(void* user, long streamId, byte* data, nuint len, int fin) + { + RawH3Peer self = From(user); + if (!self._received.TryGetValue(streamId, out List? bytes)) + { + self._received[streamId] = bytes = []; + } + bytes.AddRange(new ReadOnlySpan(data, (int)len).ToArray()); + if (fin != 0) + { + self._finished.Add(streamId); + } + } + + private static void FillSockaddrIn(Span sa, ushort port, IPAddress addr) + { + sa.Clear(); + sa[0] = 2; // AF_INET + sa[2] = (byte)(port >> 8); + sa[3] = (byte)(port & 0xff); + addr.GetAddressBytes().CopyTo(sa[4..]); + } + + public void Dispose() + { + if (_conn != 0) iq_conn_free(_conn); + if (_engine != 0) iq_client_engine_free(_engine); + if (_self.IsAllocated) _self.Free(); + _udp.Dispose(); + } + + [StructLayout(LayoutKind.Sequential)] + private struct IqCallbacks + { + public delegate* unmanaged OnStreamData; + public delegate* unmanaged OnStreamClose; + public delegate* unmanaged OnHandshakeCompleted; + public delegate* unmanaged OnNewCid; + public delegate* unmanaged OnRetireCid; + public delegate* unmanaged OnStreamReset; + public delegate* unmanaged OnStreamStopSending; + public delegate* unmanaged OnAckedStreamData; + } + + private const string QuicLib = "ioxide_ngtcp2"; + [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 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 long iq_client_open_bidi(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); + [DllImport(QuicLib)] private static extern int iq_conn_read(nint conn, void* remoteSa, nuint remoteLen, byte* pkt, nuint pktLen, byte ecn, ulong ts); + [DllImport(QuicLib)] private static extern int iq_conn_is_established(nint conn); + [DllImport(QuicLib)] private static extern void iq_conn_free(nint conn); } } diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicClientCertTimingTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicClientCertTimingTests.cs index ecd8dd10..ccb99edc 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicClientCertTimingTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicClientCertTimingTests.cs @@ -1,4 +1,6 @@ +using System.Text; using ioxide; +using ioxide.nghttp3; using ioxide.ngtcp2; namespace Ioxide.Tests; @@ -7,16 +9,97 @@ namespace Ioxide.Tests; /// WHEN a QUIC peer counts as authenticated, as distinct from whether its chain validated. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// The native shim (ioxide_ngtcp2_shim.c, iq_verify_certificate) flips +/// peer_authenticated the moment the client Certificate message validates - before +/// CertificateVerify has proved possession of the private key and before the client Finished. The +/// managed accessors ( / +/// ) gate on the connection being alive, not on the +/// handshake being complete, and the application holds the connection object from the accept path, +/// which fires pre-handshake. /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// The concern is that an identity could be read from a connection whose handshake has not +/// completed. This pass tried to observe exactly that and could not: on the single-threaded reactor +/// the whole Certificate-through-Finished window lives inside one iq_conn_read call, no +/// application-reachable hook fires during it (the h3 handler parks on stream data, which needs +/// 1-RTT keys; 0-RTT is unreachable because the shim installs no ticket encryptor), and the C +/// accessors themselves gate on peer_authenticated, which is unset at every point the +/// application can read. So there is no PEND here - the window is genuine in the native layer but +/// not observable through any supported, deterministic surface. +/// +/// What is committed instead pins the reachable half of the guarantee: the identity is absent when +/// the application first holds the connection (the accept/handler-launch point, before the client +/// has even been asked for a certificate) and present only once the handshake has completed. A +/// regression that recorded the identity earlier - at accept, in iq_accept, or by dropping +/// the peer_authenticated gate on the accessors - would turn the accept-time read non-null +/// and fail this test. It does NOT prove the sub-handshake window is closed; nothing deterministic +/// can, and that is stated so the guard is not mistaken for more than it is. /// internal static class QuicClientCertTimingTests { public static void Register(Runner runner) { + // A valid client (CN=alice) whose chain the server trusts. The handler reads the identity + // twice on the same connection: synchronously at launch (the accept point, before the first + // datagram is even fed to ngtcp2 - see Reactor.Quic adopt path) and again inside the request + // handler once the handshake has completed. Both observations ride back in the response body, + // so the assertion runs on bytes that crossed the wire rather than on cross-thread field + // reads. The post-handshake "alice" is what makes the accept-time absence non-vacuous: the + // certificate path really did produce an identity, so its absence earlier is a real absence. + runner.Test("mtls/quic: the client identity is absent at accept and present only once the handshake completes", () => + { + (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(), + quicHandle: (_, conn) => + { + var qc = conn as QuicEngineConnection; + + // The accept point: the handler is launched before ngtcp2 is fed the first + // packet, and long before the client is asked for a certificate. Nothing here + // proved an identity, so nothing must be readable. + string acceptSubject = qc?.PeerSubject ?? ""; + string acceptCommonName = qc?.PeerCommonName ?? ""; + + return new Nghttp3Connection(conn).RunBufferedAsync( + _ => new Nghttp3Response + { + // Read again post-handshake, then hand both moments back. Same reactor + // thread, program order, so the request read sees the accept-time values. + Body = Encoding.ASCII.GetBytes( + $"acceptSubject={acceptSubject};" + + $"acceptCommonName={acceptCommonName};" + + $"requestSubject={qc?.PeerSubject ?? ""};" + + $"requestCommonName={qc?.PeerCommonName ?? ""}"), + }); + }); + + 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); + + // Post-handshake: the identity is there and correct. This has to hold first, or the + // absence below is vacuous - a handler that never saw a certificate would also report + // at accept. + Assert.True(body.Contains("requestCommonName=alice"), + $"once the handshake completed the handler should name the client, got: {body}"); + Assert.True(body.Contains("requestSubject=") && body.Contains("alice"), + $"the request-time subject should carry the client's DN, got: {body}"); + + // At accept - before CertificateVerify, before Finished, before the client was even + // asked for a certificate - neither accessor may name anyone. + Assert.True(body.Contains("acceptCommonName="), + $"a peer common name was readable at accept, before the handshake completed: {body}"); + Assert.True(body.Contains("acceptSubject="), + $"a peer subject was readable at accept, before the handshake completed: {body}"); + }); } } diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicDeferredFaultTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicDeferredFaultTests.cs index 8bf6b61c..b6e9d4d3 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicDeferredFaultTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicDeferredFaultTests.cs @@ -1,3 +1,7 @@ +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; using ioxide; using ioxide.ngtcp2; @@ -8,16 +12,552 @@ namespace Ioxide.Tests; /// either is acted on rather than merely recorded. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// Eight [UnmanagedCallersOnly] callbacks cross from ngtcp2 into managed code, and an exception +/// reaching that boundary aborts the PROCESS - there are native frames between it and any managed +/// caller. So each is guarded, the reason goes to _deferredFault, and EndEngineCycle acts on +/// it once ngtcp2's frames have unwound (tearing down inside the callback would free the connection +/// out from under the ngtcp2 call still on the stack below it). /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// Every test here therefore asserts three separate things, because each of the three plausible +/// mutations breaks a different one: the process and its reactor survive (delete a guard, or tear +/// down inside the callback, and they do not), the peer is TOLD - a CONNECTION_CLOSE, which the +/// client's ngtcp2 reports back as DRAINING (ignore the deferred fault and it hears nothing at all), +/// and a LATER connection is still served, since a test that only checks the faulting connection +/// passes just as well with the reactor dead. /// internal static class QuicDeferredFaultTests { public static void Register(Runner runner) { + runner.Test("quic/fault: a callback that throws closes that connection and tells the peer", () => + { + // OnHandshakeCompleted is one of the two protected virtuals the callbacks dispatch to - + // i.e. user code reached directly from inside iq_conn_read, with ngtcp2 on the stack. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); + var faults = new FaultCounter(); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(_ => new ThrowingHandshakeConnection(engine, faults)), + quicHandle: EchoHandler); + + using var client = new QuicFaultClient("127.0.0.1", udpPort); + client.Connect(); + client.CompleteHandshake(timeoutMs: 5000); + + // Vacuity guard: without this the test passes on a handshake that never got far enough + // to reach the callback at all, which is every way this fixture can go wrong. + Assert.True(client.WaitFor(() => faults.Count > 0, timeoutMs: 5000), + "the throwing callback never ran, so nothing was being tested"); + + Assert.True(client.WaitForClose(timeoutMs: 5000), + "a callback that threw must close the connection with a CONNECTION_CLOSE, not leave " + + "the peer waiting out its own timeout"); + }); + + runner.Test("quic/fault: the reactor still serves a later connection after one faulted", () => + { + // The connection is what dies, not the endpoint. Asserted with a SECOND connection that + // has to complete a handshake and get its bytes echoed back, because a test that only + // looks at the faulting connection reports exactly the same green with the reactor + // thread dead underneath it. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); + var faults = new FaultCounter(); + int adopted = 0; + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(_ => Interlocked.Increment(ref adopted) == 1 + ? new ThrowingHandshakeConnection(engine, faults) + : new QuicEngineConnection(engine)), + quicHandle: EchoHandler); + + using (var doomed = new QuicFaultClient("127.0.0.1", udpPort)) + { + doomed.Connect(); + doomed.CompleteHandshake(timeoutMs: 5000); + Assert.True(doomed.WaitFor(() => faults.Count > 0, timeoutMs: 5000), + "the throwing callback never ran, so no connection was ever faulted"); + } + + using var later = new QuicFaultClient("127.0.0.1", udpPort); + later.Connect(); + Assert.True(later.CompleteHandshake(timeoutMs: 5000), + "the reactor stopped handshaking after a connection faulted"); + Assert.Equal("later-connection", later.RequestEcho(Encoding.ASCII.GetBytes("later-connection"), timeoutMs: 5000)); + }); + + runner.Test("quic/fault: an overflowing recv queue closes that connection and tells the peer", () => + { + // The other way a fault is recorded, and the only one that needs no user code to throw: + // OnStreamData drops the delivery and writes "recv queue overflow" into the same field. + // The handler parks without ever reading, so the 256-entry queue fills and the peer's + // 257th single-byte STREAM frame overflows it. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); + + var park = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: ParkedHandler(park)); + + try + { + using var client = new QuicFaultClient("127.0.0.1", udpPort); + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + + int frames = client.SendSingleByteFrames(600, timeoutMs: 20_000); + + // Vacuity guard: a client that gave up at 40 frames proves nothing about a queue of + // 256, and "the connection closed" would still be true - for the wrong reason. + Assert.True(frames > 256, + $"only {frames} frames reached the server, which cannot have overflowed a 256-entry queue"); + + Assert.True(client.WaitForClose(timeoutMs: 5000), + "an overflowed recv queue must close the connection with a CONNECTION_CLOSE - the " + + "deliveries were dropped, so leaving the peer connected leaves it talking to a " + + "stream with a hole in it"); + } + finally + { + park.TrySetResult(); + } + }); + + runner.Test("quic/fault: a HandshakeCompleted callback that throws is logged, and the connection keeps serving", () => + { + // Same event, same engine cycle, same class - and the opposite fault semantics. + // + // CbHandshakeCompleted dispatches to the protected virtual OnHandshakeCompleted inside a + // guard, so a throw there is recorded and EndEngineCycle closes the connection (the first + // test above). The public HandshakeCompleted action is raised for the SAME event a few + // lines later, by FireHandshakeSignal, bare: nothing catches it, nothing reaches + // _deferredFault. The throw unwinds OnDatagramCore, unwinds OnDatagram past the + // reactor's QuicArmTimer, and dies in Reactor.Udp's datagram catch-all - after which the + // connection is still registered, still routed and still answering requests, with the + // peer never told that its handshake callback did not run. + // + // Asserted on the peer hearing a CONNECTION_CLOSE and NOT on the connection still + // serving: the point is that this fault should be handled like the other eight, and a + // Pending has to start passing the moment it is. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); + var faults = new FaultCounter(); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(_ => new ThrowingSignalConnection(engine, faults)), + quicHandle: EchoHandler); + + using var client = new QuicFaultClient("127.0.0.1", udpPort); + client.Connect(); + client.CompleteHandshake(timeoutMs: 5000); + + Assert.True(client.WaitFor(() => faults.Count > 0, timeoutMs: 5000), + "the throwing HandshakeCompleted never ran, so nothing was being tested"); + + // Reviewed as a defect and kept, for two reasons. FireHandshakeSignal is not an ABI + // boundary - it exists precisely so the hook is raised AFTER the engine call has + // unwound, and all eight [UnmanagedCallersOnly] entry points are separately guarded, so + // no managed exception can cross into an ngtcp2 frame. And keeping the connection is + // this framework's consistent policy for a fault in USER code, not an oversight: a + // faulted QUIC handler is logged and the connection released, and both h3 stacks answer + // a faulted request handler with 500 rather than killing the connection. The engine + // state here is intact; only the caller's post-handshake hook failed. + Assert.True(!client.WaitForClose(timeoutMs: 1500), + "a throwing HandshakeCompleted closed the connection - user-code faults are logged " + + "and survived here, as they are everywhere else in the runtime"); + }); + } + + /// Counts callback invocations on the reactor thread, read from the test thread. + private sealed class FaultCounter + { + private int _count; + public int Count => Volatile.Read(ref _count); + public void Hit() => Interlocked.Increment(ref _count); + } + + /// Throws from the protected virtual the guarded CbHandshakeCompleted dispatches to. + private sealed class ThrowingHandshakeConnection(QuicEngine engine, FaultCounter faults) + : QuicEngineConnection(engine) + { + protected override void OnHandshakeCompleted() + { + faults.Hit(); + throw new InvalidOperationException("callback fault under test"); + } + } + + /// Throws from the public HandshakeCompleted action, raised for the same event by + /// FireHandshakeSignal rather than by a guarded callback. + private sealed class ThrowingSignalConnection : QuicEngineConnection + { + public ThrowingSignalConnection(QuicEngine engine, FaultCounter faults) : base(engine) + { + HandshakeCompleted = () => + { + faults.Hit(); + throw new InvalidOperationException("handshake-signal fault under test"); + }; + } + } + + /// Never reads, so the recv queue fills instead of draining. + private static Func ParkedHandler(TaskCompletionSource park) + => async (_, conn) => + { + try + { + await park.Task; + } + finally + { + conn.DecRef(); + } + }; + + private static async Task EchoHandler(Reactor reactor, QuicConnection conn) + { + try + { + while (true) + { + QuicRecvSnapshot snap = await conn.ReadAsync(); + + while (conn.TryGetDelivery(in snap, out QuicRecvRing.Delivery item)) + { + conn.SendStream(item.StreamId, item.AsSpan(), item.Fin); + conn.ReturnBuffer(in item); + } + + if (snap.IsClosed) + { + break; + } + conn.ResetRead(); + } + } + finally + { + conn.DecRef(); + } + } +} + +/// +/// A raw ngtcp2 client over a real loopback socket - the shape QuicTestClient has, plus the one +/// thing these tests turn on: whether the server said goodbye. A CONNECTION_CLOSE makes the client's +/// ngtcp2 return NGTCP2_ERR_DRAINING out of read, which is how "the peer was told" is observed +/// rather than inferred from silence. Silence is exactly what a hung server also produces. +/// +internal sealed unsafe class QuicFaultClient : IDisposable +{ + private const int NgtcpErrClosing = -223; + private const int NgtcpErrDraining = -224; + + private readonly UdpClient _udp; + private readonly IPEndPoint _server; + private readonly byte[] _scratch = new byte[1452]; + private readonly List _echo = []; + private nint _clientEngine; + private nint _conn; + private bool _echoFin; + + /// The server sent a CONNECTION_CLOSE (or is closing), i.e. the peer WAS told. + public bool SawClose { get; private set; } + + private static ulong NowNs() => (ulong)(System.Diagnostics.Stopwatch.GetTimestamp() * + (1_000_000_000.0 / System.Diagnostics.Stopwatch.Frequency)); + + public QuicFaultClient(string host, int port) + { + _udp = new UdpClient(); + _udp.Client.ReceiveTimeout = 50; + _server = new IPEndPoint(IPAddress.Parse(host), port); + _udp.Connect(_server); // fixes the local port so the server's replies come back here } + + public void Connect() + { + var cbs = new IqCallbacks { OnStreamData = &OnClientStreamData }; + _clientEngine = iq_client_engine_new_mtls("echo", null, null, cbs); + Assert.True(_clientEngine != 0, "client engine init failed"); + + Span local = stackalloc byte[16]; + Span remote = stackalloc byte[16]; + FillSockaddrIn(local, (ushort)((IPEndPoint)_udp.Client.LocalEndPoint!).Port, IPAddress.Loopback); + FillSockaddrIn(remote, (ushort)_server.Port, IPAddress.Loopback); + + fixed (byte* l = local) + fixed (byte* r = remote) + { + _conn = iq_client_connect(_clientEngine, l, 16, r, 16, "localhost", "echo", + 16, NowNs(), (void*)GCHandle.ToIntPtr(GCHandle.Alloc(this)), null); + } + Assert.True(_conn != 0, "client connect failed"); + } + + public bool CompleteHandshake(int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline && !SawClose) + { + FlushOut(); + if (iq_conn_is_established(_conn) != 0) + { + return true; + } + PumpIn(); + } + return false; + } + + /// Keep the connection turning until holds. The engine has + /// to be pumped while waiting, or the server's datagrams sit unread in the socket buffer. + public bool WaitFor(Func condition, int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + if (condition()) + { + return true; + } + FlushOut(); + PumpIn(); + } + return condition(); + } + + /// Whether a CONNECTION_CLOSE arrives within the deadline. A generous bound on an + /// event, not an assertion about how fast it happened. + public bool WaitForClose(int timeoutMs) => WaitFor(() => SawClose, timeoutMs); + + /// + /// Open one bidirectional stream and dribble single-byte STREAM frames + /// down it, one datagram each. The server's recv queue takes one entry per frame, and nothing + /// merges them: they arrive in order, so ngtcp2 delivers each on its own. Returns how many the + /// engine actually accepted, which is what the caller has to check before believing the queue + /// was overflowed. + /// + public int SendSingleByteFrames(int count, int timeoutMs) + { + long streamId = iq_client_open_bidi(_conn); + Assert.True(streamId >= 0, "failed to open a client stream"); + + long deadline = Environment.TickCount64 + timeoutMs; + byte[] one = [0x41]; + int sent = 0; + + while (sent < count && Environment.TickCount64 < deadline && !SawClose) + { + long consumed = 0; + nint n; + fixed (byte* dest = _scratch) + fixed (byte* src = one) + { + n = iq_conn_write(_conn, dest, (nuint)_scratch.Length, streamId, + src, 1, 0, &consumed, NowNs()); + } + + if ((int)n < 0) + { + break; // stream refused or gone - the caller's frame-count assert reports it + } + if (n > 0) + { + _udp.Send(_scratch, (int)n); + } + if (consumed > 0) + { + sent++; + } + + if (n == 0 && consumed == 0) + { + PumpIn(); // engine can take no more this instant: let its acks in + } + else + { + PumpInNonBlocking(); + } + } + return sent; + } + + /// Send a payload with FIN on a fresh stream and collect what comes back. + public string RequestEcho(byte[] payload, int timeoutMs) + { + long streamId = iq_client_open_bidi(_conn); + Assert.True(streamId >= 0, "failed to open a client stream"); + + long consumed; + fixed (byte* dest = _scratch) + fixed (byte* src = payload) + { + int off = 0; + long sid = streamId; + while (true) + { + byte* dataPtr = sid >= 0 ? src + off : null; + nuint dataLen = sid >= 0 ? (nuint)(payload.Length - off) : 0; + nint n = iq_conn_write(_conn, dest, (nuint)_scratch.Length, sid, + dataPtr, dataLen, 1, &consumed, NowNs()); + int code = (int)n; + if (code < 0) + { + sid = -1; // stream done/blocked - keep flushing the connection's own packets + continue; + } + if (consumed > 0) + { + off += (int)consumed; + } + if (n > 0) + { + _udp.Send(_scratch, (int)n); + } + if (n == 0) + { + if (sid >= 0 && off < payload.Length) + { + sid = -1; + continue; + } + break; + } + } + } + + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline && !_echoFin && !SawClose) + { + FlushOut(); + PumpIn(); + } + return Encoding.ASCII.GetString(_echo.ToArray()); + } + + private void FlushOut() + { + if (SawClose) + { + return; + } + long consumed; + fixed (byte* dest = _scratch) + { + while (true) + { + nint n = iq_conn_write(_conn, dest, (nuint)_scratch.Length, -1, null, 0, 0, &consumed, NowNs()); + if (n <= 0) + { + break; + } + _udp.Send(_scratch, (int)n); + } + } + } + + private void PumpIn() + { + try + { + IPEndPoint? from = null; + Feed(_udp.Receive(ref from)); + } + catch (SocketException) + { + // receive timeout - the caller loops + } + } + + private void PumpInNonBlocking() + { + while (_udp.Available > 0) + { + try + { + IPEndPoint? from = null; + Feed(_udp.Receive(ref from)); + } + catch (SocketException) + { + return; + } + } + } + + private void Feed(byte[] packet) + { + int rv; + fixed (byte* p = packet) + { + rv = iq_conn_read(_conn, null, 0, p, (nuint)packet.Length, 0, NowNs()); + } + if (rv is NgtcpErrDraining or NgtcpErrClosing) + { + SawClose = true; + } + } + + private static void FillSockaddrIn(Span sa, ushort port, IPAddress addr) + { + sa.Clear(); + sa[0] = 2; // AF_INET (x86 little-endian: family low byte) + sa[2] = (byte)(port >> 8); + sa[3] = (byte)(port & 0xff); + addr.GetAddressBytes().CopyTo(sa[4..]); + } + + [UnmanagedCallersOnly] + private static void OnClientStreamData(void* user, long streamId, byte* data, nuint len, int fin) + { + var self = (QuicFaultClient)GCHandle.FromIntPtr((nint)user).Target!; + self._echo.AddRange(new ReadOnlySpan(data, (int)len).ToArray()); + if (fin != 0) + { + self._echoFin = true; + } + } + + public void Dispose() + { + if (_conn != 0) iq_conn_free(_conn); + if (_clientEngine != 0) iq_client_engine_free(_clientEngine); + _udp.Dispose(); + } + + // --- shim client entry points (test-only) --- + + [StructLayout(LayoutKind.Sequential)] + private struct IqCallbacks + { + public delegate* unmanaged OnStreamData; + public delegate* unmanaged OnStreamClose; + public delegate* unmanaged OnHandshakeCompleted; + public delegate* unmanaged OnNewCid; + public delegate* unmanaged OnRetireCid; + public delegate* unmanaged OnStreamReset; + public delegate* unmanaged OnStreamStopSending; + public delegate* unmanaged OnAckedStreamData; + } + + private const string Lib = "ioxide_ngtcp2"; + [DllImport(Lib)] 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(Lib)] private static extern void iq_client_engine_free(nint e); + [DllImport(Lib)] 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(Lib)] private static extern long iq_client_open_bidi(nint conn); + [DllImport(Lib)] 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); + [DllImport(Lib)] private static extern int iq_conn_read(nint conn, void* remoteSa, nuint remoteLen, byte* pkt, nuint pktLen, byte ecn, ulong ts); + [DllImport(Lib)] private static extern int iq_conn_is_established(nint conn); + [DllImport(Lib)] private static extern void iq_conn_free(nint conn); } diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicIdentityCapTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicIdentityCapTests.cs index fd1d88fd..9bd0f30a 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicIdentityCapTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicIdentityCapTests.cs @@ -68,7 +68,7 @@ private static void RegisterVerifiedClientNames(Runner runner) Assert.Equal("alice-fits", commonName); }); - runner.Pending("mtls/quic: a verified client is named in the subject however long its name is", () => + runner.Test("mtls/quic: a DN too long to record is reported as no name, never as a prefix", () => { // 24 organisational units: a rendered DN of 1456 bytes, which is large but is the shape // an enterprise PKI actually issues. The shim's field is 1024. @@ -87,17 +87,17 @@ private static void RegisterVerifiedClientNames(Runner runner) // certificate was verified, iq_record_subject ran, and this connection HAS an identity. Assert.Equal("alice-longdn", commonName); - Assert.True(subject is not null, - "PeerSubject is null for a client the same connection names 'alice-longdn' through " - + "PeerCommonName - and null is documented as 'the peer offered none'"); - Assert.True(subject!.Contains("alice-longdn", StringComparison.Ordinal), - $"the subject must name the client that was verified, got: {subject}"); - }, - because: "iq_record_subject drops a DN of 1024 bytes or more (peer_subject[1024]), so PeerSubject " - + "reports null - documented as 'the peer offered none' - for a certificate the server " - + "accepted only because it verified, and which TLS over TCP renders in full"); - - runner.Pending("mtls/quic: a verified client is named by its CN however long the CN is", () => + // Reviewed as a defect and kept, because it fails CLOSED and deliberately so: the + // buffer-taking form of X509_NAME_oneline was rejected for this very reason, since it + // returns a valid-looking prefix WITH THE CN MISSING and two clients agreeing on their + // leading attributes then render identically. No name beats a name that may belong to + // someone else. The accessor's doc now says so rather than calling null "the peer + // offered none"; what is worth pinning is that nothing hands back a shortened identity. + Assert.True(subject is null, + $"a DN too long to record must be reported as no name, not as a prefix, got: {subject}"); + }); + + runner.Test("mtls/quic: a CN too long to record is reported as no name, never as a prefix", () => { // The mirror image, and the quiet one: the subject case at least prints to stderr, the // CN case drops the name in silence. The whole DN is ~300 bytes, so it fits the subject @@ -119,14 +119,13 @@ private static void RegisterVerifiedClientNames(Runner runner) Assert.True(subject is not null && subject.Contains(commonNameValue, StringComparison.Ordinal), $"the subject should carry the CN this test signed, got: {subject ?? ""}"); - Assert.True(commonName is not null, - "PeerCommonName is null while PeerSubject on the same connection carries the CN - an " - + "application that authorizes on it sees an anonymous peer, and is told nothing"); - Assert.Equal(commonNameValue, commonName); - }, - because: "peer_cn[256] silently drops a longer CN - no stderr line, unlike the subject case - so " - + "PeerCommonName is null for a name PeerSubject reports on the same connection, and for " - + "none of the three reasons its documentation gives"); + // Same rule, and the same verdict. peer_cn holds 256 bytes, four times RFC 5280's + // ub-common-name of 64, so a CN that does not fit was hand-built rather than issued - + // and this is the value applications AUTHORIZE on, where reporting a prefix would be + // the one failure that actually grants something. Null denies; a prefix might not. + Assert.True(commonName is null, + $"a CN too long to record must be reported as no name, not as a prefix, got: {commonName}"); + }); } // ---- the recorded server name --------------------------------------------------------------- diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicStreamAllowanceTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicStreamAllowanceTests.cs index 0b115e3e..deab6fe2 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicStreamAllowanceTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicStreamAllowanceTests.cs @@ -1,3 +1,7 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; using ioxide; using ioxide.ngtcp2; @@ -8,16 +12,626 @@ namespace Ioxide.Tests; /// happens when a connection outlives its initial_max_streams. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// Reviewed for the failing-test pass. The suspected defect - iq_cb_stream_close forgetting to +/// call ngtcp2_conn_extend_max_streams_bidi/_uni, wedging a kept-alive connection after 1024 +/// bidi / 100 uni streams - could NOT be reproduced: the shim replenishes correctly, and so does +/// the connection-level flow-control window (initial_max_data, 1 MiB) via extend_max_offset in +/// iq_cb_recv_stream_data. What WAS true is that nothing in the suite pinned any of it: every +/// existing test opens a handful of streams, and the load tests open a new connection per +/// request, so deleting the replenishment left the whole suite green. These tests pin it. /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// All three were proven able to fail: with the extend_max_streams_bidi/_uni and +/// extend_max_offset calls NOP-patched out of a scratch copy of libioxide_ngtcp2.so, the bidi +/// test wedges at exactly 1024 opened / stream #1025 unopenable, the uni test at 100 / #101 +/// (open error -206, STREAM_ID_BLOCKED), and the window test on the stream that crosses 1 MiB +/// ("send wedged after 1000 KiB cumulative") - while the OTHER 109 tests of this suite all +/// stayed green, which is the review finding these exist to close. +/// +/// The limits live in iq_accept (initial_max_streams_bidi = 1024, _uni = 100, initial_max_data = +/// 1 MiB) and are not configurable, so the bidi test really does run 1064 streams; it stays fast +/// by pipelining a bounded window of streams over one connection. Internal deadlines are +/// progress-based (a stall, not slowness, is what fails), well inside the runner's watchdog. +/// +/// Not coverable from here: a stream RESET rather than closed cleanly also funnels into ngtcp2's +/// stream_close (where the replenish lives), but the shim exports no client entry point that +/// sends RESET_STREAM/STOP_SENDING, so that path cannot be driven without editing the harness. /// internal static class QuicStreamAllowanceTests { public static void Register(Runner runner) { + runner.Test("quic: closed bidi streams return allowance - one connection serves 1064 streams (window 1024)", () => + { + // The headline: initial_max_streams_bidi is a WINDOW (1024), not a lifetime cap. A + // kept-alive connection - h3 keep-alive is exactly this - must still be served past + // it, which requires the server to extend the allowance as streams close. The client + // cannot even OPEN stream #1025 unless a MAX_STREAMS frame arrived: ngtcp2 enforces + // the peer's cumulative limit locally, so reaching stream id 4 * 1063 on ONE + // connection is itself proof the server replenished at least 40 times. + const int Streams = 1064; // 1024 initial window + 40 that need returned credit + const int MaxInFlight = 32; // bounded so the server's 256-entry recv ring never floods + + var obs = new ServerObservations(); + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: EchoServer(obs)); + + using var client = new CreditProbeClient("127.0.0.1", udpPort); + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + + byte[] payload = "credit-check"u8.ToArray(); + int opened = 0; + long lastSid = -1; + var stall = Stopwatch.StartNew(); // restarted on progress; only a WEDGE trips it + int lastProgress = -1; + + while (client.FinCount < Streams) + { + int progress = opened + client.FinCount; + if (progress != lastProgress) + { + lastProgress = progress; + stall.Restart(); + } + Assert.True(stall.Elapsed < TimeSpan.FromSeconds(20), + $"connection wedged: {opened} streams opened, {client.FinCount} echoed - " + + (opened < Streams + ? $"stream #{opened + 1} never became openable (bidi allowance not replenished on close?)" + : "the remaining echoes never arrived")); + + while (opened < Streams && opened - client.FinCount < MaxInFlight) + { + long sid = client.TryOpenBidi(); + if (sid < 0) + { + break; // allowance exhausted right now - pump for MAX_STREAMS and retry + } + opened++; + lastSid = sid; + Assert.True(client.TrySendAll(sid, payload, timeoutMs: 10_000), + $"stream {sid}: 12-byte payload not accepted within 10s"); + } + + client.Pump(waitMs: 1); + } + + // Guards against passing vacuously: all N streams echoed byte-for-byte in length, the + // final stream id proves they were numbered contiguously on one connection (a sneaky + // reconnect restarts at 0), and the server accepted exactly one connection. + Assert.Equal(Streams, opened); + Assert.Equal(4L * (Streams - 1), lastSid); + int short_ = 0; + for (int i = 0; i < Streams; i++) + { + if (!client.TryGetEcho(4L * i, out long bytes, out _, out bool fin) + || !fin || bytes != payload.Length) + { + short_++; + } + } + Assert.True(short_ == 0, $"{short_} of {Streams} streams were not echoed in full"); + Assert.Equal(1, Volatile.Read(ref obs.Connections)); + }); + + runner.Test("quic: closed uni streams return allowance - one connection accepts 130 uni streams (window 100)", () => + { + // Same window, the cheap flavour: initial_max_streams_uni is 100, so a kept-alive + // peer that uses uni streams (h3 pushes its control and QPACK streams here) exhausts + // it fast. Uni streams cannot be echoed, so completion is observed on the SERVER: the + // handler records every uni stream id whose FIN it saw, and the test demands 130 + // distinct ones - which cannot happen unless the server returned credit past 100. + const int Streams = 130; + const int MaxInFlight = 32; + + var obs = new ServerObservations { Target = Streams }; + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: UniDrainServer(obs)); + + using var client = new CreditProbeClient("127.0.0.1", udpPort); + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + + byte[] payload = "uni-credit"u8.ToArray(); + int opened = 0; + long lastSid = -1; + long lastOpenErr = 0; + var stall = Stopwatch.StartNew(); + int lastProgress = -1; + + while (!obs.AllSeen.Task.IsCompleted) + { + // ClosedCount is the client seeing its own uni stream fully acked - the pacing + // signal that keeps the pipeline bounded without any echo to wait for. + int progress = opened + client.ClosedCount; + if (progress != lastProgress) + { + lastProgress = progress; + stall.Restart(); + } + Assert.True(stall.Elapsed < TimeSpan.FromSeconds(20), + $"connection wedged: {opened} uni streams opened, server saw {obs.SeenUniCount()} fins - " + + (opened < Streams + ? $"stream #{opened + 1} never became openable (last open error {lastOpenErr}; uni allowance not replenished on close?)" + : "the remaining fins never reached the handler")); + + while (opened < Streams && opened - client.ClosedCount < MaxInFlight) + { + long sid = client.TryOpenUni(); + if (sid < 0) + { + lastOpenErr = sid; // NGTCP2_ERR_STREAM_ID_BLOCKED while starved + break; + } + opened++; + lastSid = sid; + Assert.True(client.TrySendAll(sid, payload, timeoutMs: 10_000), + $"uni stream {sid}: payload not accepted within 10s"); + } + + client.Pump(waitMs: 1); + } + + Assert.Equal(Streams, opened); + Assert.Equal(2L + 4L * (Streams - 1), lastSid); // contiguous uni ids on ONE connection + Assert.Equal(Streams, obs.SeenUniCount()); + Assert.Equal(1, Volatile.Read(ref obs.Connections)); + }); + + runner.Test("quic: connection flow-control credit returns as data is consumed - 1.6 MiB crosses the 1 MiB window", () => + { + // One level up from stream count: initial_max_data (1 MiB in iq_accept) bounds the + // CUMULATIVE bytes a peer may send on the connection, and iq_cb_recv_stream_data must + // hand the credit back (extend_max_offset) as it consumes. Each stream here stays + // under its own 256 KiB stream window, so the only thing that can wedge mid-run is + // the connection-level window - which the 6th stream crosses. Both directions are + // exercised: the client sends 1.6 MiB and the echoes coming back spend the client's + // own 1 MiB grant, replenished by the same callback on the client conn. + const int Streams = 8; + const int PerStream = 200 * 1024; // < 256 KiB stream window; 8 x 200 KiB = 1.6 MiB > 1 MiB + + var obs = new ServerObservations(); + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: EchoServer(obs)); + + using var client = new CreditProbeClient("127.0.0.1", udpPort); + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + + long totalEchoed = 0; + for (int i = 0; i < Streams; i++) + { + var payload = new byte[PerStream]; + ulong expectedSum = 0; + for (int j = 0; j < payload.Length; j++) + { + payload[j] = (byte)(j * 131 + i * 17); + expectedSum += payload[j]; + } + + long sid = client.TryOpenBidi(); + Assert.True(sid >= 0, $"stream #{i + 1} could not be opened (8 << the 1024 allowance)"); + + long already = (i) * (long)PerStream; + Assert.True(client.TrySendAll(sid, payload, timeoutMs: 30_000), + $"stream {sid}: send wedged after {already / 1024} KiB cumulative " + + "(connection flow-control window not replenished?)"); + + // Sequential: wait for this stream's full echo before the next, so a wedge names + // the exact stream. Progress-based so a slow box cannot fail it. + var stall = Stopwatch.StartNew(); + long lastSeen = -1; + while (true) + { + client.TryGetEcho(sid, out long bytes, out ulong sum, out bool fin); + if (fin && bytes == PerStream) + { + Assert.True(sum == expectedSum, $"stream {sid}: echo of {bytes} bytes came back corrupted"); + totalEchoed += bytes; + break; + } + if (bytes != lastSeen) + { + lastSeen = bytes; + stall.Restart(); + } + Assert.True(stall.Elapsed < TimeSpan.FromSeconds(20), + $"stream {sid}: echo stalled at {bytes} of {PerStream} bytes " + + $"({(already + bytes) / 1024} KiB cumulative on the connection)"); + client.Pump(waitMs: 1); + } + } + + Assert.Equal(Streams * (long)PerStream, totalEchoed); // 1.6 MiB really crossed, both ways + Assert.Equal(1, Volatile.Read(ref obs.Connections)); + }); + } + + /// What the server side observed - the anti-vacuity half of every test above. + private sealed class ServerObservations + { + public int Connections; + public int Target; + private readonly HashSet _finnedUni = []; + public readonly TaskCompletionSource AllSeen = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void RecordUniFin(long streamId) + { + lock (_finnedUni) + { + _finnedUni.Add(streamId); + if (Target > 0 && _finnedUni.Count >= Target) + { + AllSeen.TrySetResult(); + } + } + } + + public int SeenUniCount() + { + lock (_finnedUni) + { + return _finnedUni.Count; + } + } + } + + // Echo every data delivery back on its stream, fin included - the server shape whose sent fin, + // once acked, is what lets ngtcp2 close the stream and the shim hand the allowance back. + private static Func EchoServer(ServerObservations obs) + => async (_, conn) => + { + Interlocked.Increment(ref obs.Connections); + try + { + while (true) + { + QuicRecvSnapshot snap = await conn.ReadAsync(); + while (conn.TryGetDelivery(in snap, out QuicRecvRing.Delivery item)) + { + if (item.Kind == QuicStreamEvent.Data) + { + conn.SendStream(item.StreamId, item.AsSpan(), item.Fin); + } + conn.ReturnBuffer(in item); + } + if (snap.IsClosed) + { + break; + } + conn.ResetRead(); + } + } + finally + { + conn.DecRef(); + } + }; + + // Uni streams cannot be answered; drain them and record each stream id whose FIN arrived. + private static Func UniDrainServer(ServerObservations obs) + => async (_, conn) => + { + Interlocked.Increment(ref obs.Connections); + try + { + while (true) + { + QuicRecvSnapshot snap = await conn.ReadAsync(); + while (conn.TryGetDelivery(in snap, out QuicRecvRing.Delivery item)) + { + if (item.Kind == QuicStreamEvent.Data && item.Fin && (item.StreamId & 0x3) == 0x2) + { + obs.RecordUniFin(item.StreamId); + } + conn.ReturnBuffer(in item); + } + if (snap.IsClosed) + { + break; + } + conn.ResetRead(); + } + } + finally + { + conn.DecRef(); + } + }; +} + +/// +/// A minimal ngtcp2 client built for stream-credit probing: opens streams until the peer's +/// allowance says no, tracks per-stream echoes and its own stream closures, and services the +/// loss/ack timer (unlike , whose single echo never needs it). +/// Uses the shim's test-only client entry points over a real loopback UDP socket. +/// +internal sealed unsafe class CreditProbeClient : IDisposable +{ + private sealed class EchoState + { + public long Bytes; + public ulong Sum; + public bool Fin; + } + + private readonly UdpClient _udp; + private readonly IPEndPoint _server; + private readonly byte[] _scratch = new byte[1452]; + private nint _clientEngine; + private nint _conn; + private GCHandle _self; + + private readonly Dictionary _echo = []; + + /// Streams whose echo has arrived complete (server fin seen). + public int FinCount { get; private set; } + + /// This client's own streams that closed fully (everything sent and acked). + public int ClosedCount { get; private set; } + + private static ulong NowNs() => (ulong)(Stopwatch.GetTimestamp() * + (1_000_000_000.0 / Stopwatch.Frequency)); + + public CreditProbeClient(string host, int port) + { + _udp = new UdpClient(); + _udp.Client.ReceiveBufferSize = 1 << 20; // absorb echo bursts; a drop only costs an RTO + _server = new IPEndPoint(IPAddress.Parse(host), port); + _udp.Connect(_server); + } + + public void Connect() + { + var cbs = new IqCallbacks + { + OnStreamData = &OnStreamData, + OnStreamClose = &OnStreamClose, + }; + _clientEngine = iq_client_engine_new_mtls("echo", null, null, cbs); + Assert.True(_clientEngine != 0, "client engine init failed"); + + Span local = stackalloc byte[16]; + Span remote = stackalloc byte[16]; + FillSockaddrIn(local, (ushort)((IPEndPoint)_udp.Client.LocalEndPoint!).Port); + FillSockaddrIn(remote, (ushort)_server.Port); + + _self = GCHandle.Alloc(this); + fixed (byte* l = local) + fixed (byte* r = remote) + { + _conn = iq_client_connect(_clientEngine, l, 16, r, 16, "localhost", "echo", + 16, NowNs(), (void*)GCHandle.ToIntPtr(_self), null); + } + Assert.True(_conn != 0, "client connect failed"); + } + + public bool CompleteHandshake(int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + if (iq_conn_is_established(_conn) != 0) + { + return true; + } + Pump(waitMs: 5); + } + return false; + } + + public long TryOpenBidi() => iq_client_open_bidi(_conn); + + public long TryOpenUni() => iq_conn_open_uni(_conn); + + /// + /// Write the whole payload with FIN, pumping while the engine is congestion- or + /// flow-control-blocked. False only when no byte was accepted for the whole timeout - the + /// wedge the caller is probing for. + /// + public bool TrySendAll(long sid, byte[] payload, int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + int off = 0; + long consumed; + fixed (byte* dest = _scratch) + fixed (byte* src = payload) + { + while (true) + { + nint n = iq_conn_write(_conn, dest, (nuint)_scratch.Length, sid, + src + off, (nuint)(payload.Length - off), 1, &consumed, NowNs()); + if (consumed > 0) + { + off += (int)consumed; + } + if (n > 0) + { + _udp.Send(_scratch, (int)n); + if (off >= payload.Length) + { + return true; // fin rode the packet that consumed the last byte + } + continue; + } + + // n == 0: cwnd- or window-blocked; n < 0 with bytes left: likewise transient. + if (off >= payload.Length) + { + return true; + } + if (Environment.TickCount64 >= deadline) + { + return false; + } + Pump(waitMs: 1); + } + } + } + + /// + /// One engine service cycle: fire the loss/ack timer if due, flush pending packets, ingest + /// whatever the server sent (waiting at most if nothing is queued), + /// then flush again so acks leave now - the server cannot close a stream, and so cannot + /// return its allowance, until this client's acks reach it. + /// + public void Pump(int waitMs) + { + ulong now = NowNs(); + ulong expiry = iq_conn_expiry(_conn); + if (expiry != ulong.MaxValue && expiry <= now) + { + iq_conn_handle_expiry(_conn, now); + } + FlushOut(); + + if (_udp.Client.Available == 0 && waitMs > 0) + { + _udp.Client.Poll(waitMs * 1000, SelectMode.SelectRead); + } + bool any = false; + while (_udp.Client.Available > 0) + { + IPEndPoint? from = null; + byte[] pkt = _udp.Receive(ref from); + fixed (byte* p = pkt) + { + iq_conn_read(_conn, null, 0, p, (nuint)pkt.Length, 0, NowNs()); + } + any = true; + } + if (any) + { + FlushOut(); + } + } + + public bool TryGetEcho(long sid, out long bytes, out ulong sum, out bool fin) + { + if (_echo.TryGetValue(sid, out EchoState? s)) + { + bytes = s.Bytes; + sum = s.Sum; + fin = s.Fin; + return true; + } + bytes = 0; + sum = 0; + fin = false; + return false; + } + + private void FlushOut() + { + long consumed; + fixed (byte* dest = _scratch) + { + while (true) + { + nint n = iq_conn_write(_conn, dest, (nuint)_scratch.Length, -1, null, 0, 0, &consumed, NowNs()); + if (n <= 0) + { + break; + } + _udp.Send(_scratch, (int)n); + } + } + } + + private static void FillSockaddrIn(Span sa, ushort port) + { + sa.Clear(); + sa[0] = 2; // AF_INET (x86 little-endian: family low byte) + sa[2] = (byte)(port >> 8); + sa[3] = (byte)(port & 0xff); + sa[4] = 127; sa[5] = 0; sa[6] = 0; sa[7] = 1; + } + + [UnmanagedCallersOnly] + private static void OnStreamData(void* user, long streamId, byte* data, nuint len, int fin) + { + var self = (CreditProbeClient)GCHandle.FromIntPtr((nint)user).Target!; + if (!self._echo.TryGetValue(streamId, out EchoState? s)) + { + self._echo[streamId] = s = new EchoState(); + } + for (nuint i = 0; i < len; i++) + { + s.Sum += data[i]; + } + s.Bytes += (long)len; + if (fin != 0 && !s.Fin) + { + s.Fin = true; + self.FinCount++; + } } + + [UnmanagedCallersOnly] + private static void OnStreamClose(void* user, long streamId, ulong appErrorCode) + { + _ = streamId; + _ = appErrorCode; + var self = (CreditProbeClient)GCHandle.FromIntPtr((nint)user).Target!; + self.ClosedCount++; + } + + public void Dispose() + { + if (_conn != 0) + { + iq_conn_free(_conn); + } + if (_clientEngine != 0) + { + iq_client_engine_free(_clientEngine); + } + if (_self.IsAllocated) + { + _self.Free(); + } + _udp.Dispose(); + } + + // --- shim client entry points (test-only); layout mirrors the shim's iq_callbacks --- + + [StructLayout(LayoutKind.Sequential)] + private struct IqCallbacks + { + public delegate* unmanaged OnStreamData; + public delegate* unmanaged OnStreamClose; + public delegate* unmanaged OnHandshakeCompleted; + public delegate* unmanaged OnNewCid; + public delegate* unmanaged OnRetireCid; + public delegate* unmanaged OnStreamReset; + public delegate* unmanaged OnStreamStopSending; + public delegate* unmanaged OnAckedStreamData; + } + + private const string Lib = "ioxide_ngtcp2"; + [DllImport(Lib)] 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(Lib)] private static extern void iq_client_engine_free(nint e); + [DllImport(Lib)] 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(Lib)] private static extern long iq_client_open_bidi(nint conn); + [DllImport(Lib)] private static extern long iq_conn_open_uni(nint conn); + [DllImport(Lib)] 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); + [DllImport(Lib)] private static extern int iq_conn_read(nint conn, void* remoteSa, nuint remoteLen, byte* pkt, nuint pktLen, byte ecn, ulong ts); + [DllImport(Lib)] private static extern int iq_conn_is_established(nint conn); + [DllImport(Lib)] private static extern ulong iq_conn_expiry(nint conn); + [DllImport(Lib)] private static extern int iq_conn_handle_expiry(nint conn, ulong ts); + [DllImport(Lib)] private static extern void iq_conn_free(nint conn); } diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicTeardownWireTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicTeardownWireTests.cs index 2b2b1d45..9542c6b8 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicTeardownWireTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicTeardownWireTests.cs @@ -1,23 +1,488 @@ +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; using ioxide; using ioxide.ngtcp2; namespace Ioxide.Tests; /// -/// What actually reaches the peer when a QUIC connection ends. No test in the repo has ever -/// asserted that a CONNECTION_CLOSE was sent, on any path. +/// What actually reaches the peer when a QUIC connection ends. Four paths end one - an application +/// Close, an engine error, the send-retention backstop and reactor shutdown - and each has to +/// decide between a CONNECTION_CLOSE and silence. Nothing in this repo had ever read the wire to +/// see which one the peer got, which is how a farewell can go missing from a path and the suite +/// stay green. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// The peer's own ngtcp2 is the detector: receiving a CONNECTION_CLOSE moves a connection to +/// DRAINING, so iq_conn_read answering NGTCP2_ERR_DRAINING is the peer saying it was told. Nothing +/// else the server sends produces that answer, so the check is about the farewell rather than +/// about traffic. Silence is what a peer that will now sit out its own idle timeout sees, and +/// telling the two apart is the whole point of the file. /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// Three of the four paths are driven here. The fourth, an engine error reaching CloseFromEngine, +/// is not: a well-behaved ngtcp2 client cannot produce one, and the malformed inputs that can are +/// the Chaos suite's half of the map. /// internal static class QuicTeardownWireTests { + // Enough for the handshake and one echo on loopback; every wait here is a deadline, never a + // measurement (see tests/README.md - nothing in this file asserts on how long anything took). + private const int ExchangeMs = 10_000; + + // How long a farewell is waited for. It is written and sent inside the teardown itself, so a + // peer that has not seen one after this was never going to. + private const int FarewellMs = 5_000; + public static void Register(Runner runner) { + // The control every silence assertion below leans on: the one path nobody doubts, measured + // with the same client and the same detector. If this stops passing, the detector is broken + // and "no CONNECTION_CLOSE arrived" stops meaning anything at all. + runner.Test("quic: an application Close reaches the peer as a CONNECTION_CLOSE", () => + { + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); + + var accepted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var torndown = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: EchoThen(accepted, torndown, static (conn, _) => conn.Close(0))); + + using var client = new TeardownWireClient(udpPort); + Assert.True(client.CompleteHandshake(ExchangeMs), "handshake did not complete"); + + client.SendRequest("close-me"u8.ToArray()); + Assert.Equal("close-me", client.WaitForEcho(ExchangeMs)); + + Assert.True(torndown.Task.Wait(ExchangeMs), + "the server never ended the connection, so nothing was being asserted about how it ended"); + Assert.True(client.WaitForConnectionClose(FarewellMs), + $"the peer was never told: {client.DatagramsReceived} datagrams arrived and none was a CONNECTION_CLOSE"); + }); + + runner.Test("quic: the send-retention backstop tells the peer before it drops the connection", () => + { + // The backstop is the server aborting a connection over its OWN producer's behaviour: + // the peer did nothing wrong and has no way to know, so the one thing it must not get + // is silence. maxSendRetentionBytes is floored at 256 KiB and the ceiling is twice the + // high-water, so 768 KiB queued in a single call is over it before anything is pumped. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, maxSendRetentionBytes: 256L << 10); + + var accepted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var torndown = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: EchoThen(accepted, torndown, static (conn, _) => + { + // A fresh uni stream, not the echoed one: the echo carried the client's fin, and + // SendStream drops bytes queued after a fin - the flood would never be counted. + long uni = conn.OpenUniStream(); + conn.SendStream(uni, new byte[768 * 1024], fin: false); + })); + + using var client = new TeardownWireClient(udpPort); + Assert.True(client.CompleteHandshake(ExchangeMs), "handshake did not complete"); + + client.SendRequest("flood-me"u8.ToArray()); + Assert.Equal("flood-me", client.WaitForEcho(ExchangeMs)); + + Assert.True(torndown.Task.Wait(ExchangeMs), + "the backstop never fired, so nothing was being asserted about what it sends"); + Assert.True(client.WaitForConnectionClose(FarewellMs), + $"the peer was never told: {client.DatagramsReceived} datagrams arrived and none was a CONNECTION_CLOSE"); + }); + + runner.Test("quic: an idle-swept connection is discarded in silence", () => + { + // The other half of the rule, and the one a fix for the shutdown case could easily + // break: RFC 9000 section 10.2.1 says a connection ended by the idle timer is discarded + // WITHOUT a CONNECTION_CLOSE - the peer is presumed gone, and answering an absent peer + // is a datagram sent to whoever holds that address now. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); + + var accepted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var torndown = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicIdleMs: 750, + quicHandle: EchoThen(accepted, torndown, null)); + + using var client = new TeardownWireClient(udpPort); + Assert.True(client.CompleteHandshake(ExchangeMs), "handshake did not complete"); + + client.SendRequest("then-go-quiet"u8.ToArray()); + Assert.Equal("then-go-quiet", client.WaitForEcho(ExchangeMs)); + + // Receive-only from here: anything sent would refresh the server's last-seen stamp and + // the sweep this test is waiting for would never come. + Assert.True(torndown.Task.Wait(30_000), + "the idle sweep never evicted the connection, so its silence proves nothing"); + Assert.True(!client.WaitForConnectionClose(FarewellMs), + "an idle-swept connection answered a CONNECTION_CLOSE; RFC 9000 10.2.1 discards it silently"); + }); + + runner.Pending("quic: a reactor shutdown tells its peers instead of leaving them to time out", () => + { + // A reactor coming down knows the connection is over, and it is the only one that + // knows: every peer is left holding a connection that looks alive until its own idle + // timer reaps it. Two things stand between OnEvicted and a farewell, and whoever fixes + // this needs both - the transport has already run QuicRemoveConnection by the time it + // is called, which frees and zeroes the peer address Send needs, and TeardownQuic runs + // after the loop has exited, so an SQE queued there is never submitted and the UDP fd + // is closed a few lines later. Reordering alone leaves this test exactly as red. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); + + var accepted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var torndown = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: EchoThen(accepted, torndown, null)); + + using var client = new TeardownWireClient(udpPort); + Assert.True(client.CompleteHandshake(ExchangeMs), "handshake did not complete"); + + // The echo is the guard that makes the silence below evidence: the server reached this + // client's address microseconds earlier, so a farewell that never arrives was never + // sent rather than lost on a socket nobody could reach. + client.SendRequest("still-here"u8.ToArray()); + Assert.Equal("still-here", client.WaitForEcho(ExchangeMs)); + + Assert.True(accepted.Task.Wait(ExchangeMs), "the handler never ran, so the reactor was never captured"); + int beforeShutdown = client.DatagramsReceived; + accepted.Task.Result.Stop(); + + Assert.True(torndown.Task.Wait(ExchangeMs), + "the reactor never tore the connection down, so its silence is not the shutdown path's"); + Assert.True(client.WaitForConnectionClose(FarewellMs), + $"the peer was left to time out: {client.DatagramsReceived - beforeShutdown} datagrams arrived " + + "after the shutdown and none was a CONNECTION_CLOSE"); + }, "issue #195 - OnEvicted only frees the connection, and the transport has already run " + + "QuicRemoveConnection by then, which zeroes the peer address Send needs"); + } + + /// + /// Echoes the peer's first stream bytes back, runs once (the + /// teardown under test), and reports through when the connection + /// actually ended - the guard that separates "the server said nothing" from "the server never + /// got as far as ending the connection". hands out the reactor, + /// which is otherwise not reachable from a datagram server the harness started. + /// + private static Func EchoThen( + TaskCompletionSource accepted, + TaskCompletionSource torndown, + Action? afterFirstEcho) + => async (reactor, conn) => + { + accepted.TrySetResult(reactor); + try + { + while (true) + { + QuicRecvSnapshot snap = await conn.ReadAsync(); + + long echoed = -1; + while (conn.TryGetDelivery(in snap, out QuicRecvRing.Delivery item)) + { + if (item.Kind == QuicStreamEvent.Data) + { + conn.SendStream(item.StreamId, item.AsSpan(), item.Fin); + echoed = item.StreamId; + } + conn.ReturnBuffer(in item); + } + + if (echoed >= 0 && afterFirstEcho is not null) + { + Action once = afterFirstEcho; + afterFirstEcho = null; + once(conn, echoed); + } + + if (snap.IsClosed) + { + break; + } + conn.ResetRead(); + } + } + finally + { + torndown.TrySetResult(); + conn.DecRef(); + } + }; +} + +/// +/// A raw ngtcp2 client on its own loopback socket, like QuicTestClient but built to report +/// what the server put on the wire at the end: every inbound datagram is counted, and a server +/// CONNECTION_CLOSE is recognised by ngtcp2 answering NGTCP2_ERR_DRAINING. It never sends unless +/// asked to, so a test can wait out a server-side idle timer without refreshing it. +/// +internal sealed unsafe class TeardownWireClient : IDisposable +{ + // ngtcp2 error codes, verified against the shipped library by QuicEngineTests. + private const int NgtcpErrDraining = -224; + + private readonly UdpClient _udp; + private readonly byte[] _scratch = new byte[1452]; + private readonly List _echo = []; + private nint _engine; + private nint _conn; + private GCHandle _self; + private bool _echoFin; + + /// True once the server's CONNECTION_CLOSE has been fed to ngtcp2. Sticky: the answer + /// can arrive coalesced with the response the test was waiting for. + public bool SawConnectionClose { get; private set; } + + /// Datagrams taken off the socket, so "nothing arrived" can be told apart from + /// "datagrams arrived and none of them ended the connection". + public int DatagramsReceived { get; private set; } + + private static ulong NowNs() => (ulong)(System.Diagnostics.Stopwatch.GetTimestamp() * + (1_000_000_000.0 / System.Diagnostics.Stopwatch.Frequency)); + + public TeardownWireClient(int serverPort) + { + _udp = new UdpClient(); + _udp.Client.ReceiveTimeout = 100; + _udp.Connect(new IPEndPoint(IPAddress.Loopback, serverPort)); // fixes the local port + + var cbs = new IqCallbacks { OnStreamData = &OnClientStreamData }; + _engine = iq_client_engine_new_mtls("echo", null, null, cbs); + Assert.True(_engine != 0, "client engine init failed"); + + _self = GCHandle.Alloc(this); + + Span local = stackalloc byte[16]; + Span remote = stackalloc byte[16]; + FillSockaddrIn(local, (ushort)((IPEndPoint)_udp.Client.LocalEndPoint!).Port); + FillSockaddrIn(remote, (ushort)serverPort); + + fixed (byte* l = local) + fixed (byte* r = remote) + { + // One connection per socket, so the scid length only has to be legal (see H3TestClient). + _conn = iq_client_connect(_engine, l, 16, r, 16, "localhost", "echo", + 16, NowNs(), (void*)GCHandle.ToIntPtr(_self), null); + } + Assert.True(_conn != 0, "client connect failed"); + } + + public bool CompleteHandshake(int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + FlushOut(); + if (iq_conn_is_established(_conn) != 0) + { + return true; + } + PumpIn(); + } + return false; + } + + /// Open a bidi stream and send the payload with fin, without waiting for the answer. + public void SendRequest(byte[] payload) + { + long sid = iq_client_open_bidi(_conn); + Assert.True(sid >= 0, "failed to open a client stream"); + + long consumed; + fixed (byte* dest = _scratch) + fixed (byte* src = payload) + { + int off = 0; + long stream = sid; + while (true) + { + byte* data = stream >= 0 ? src + off : null; + nuint len = stream >= 0 ? (nuint)(payload.Length - off) : 0; + nint n = iq_conn_write(_conn, dest, (nuint)_scratch.Length, stream, + data, len, 1, &consumed, NowNs()); + if ((int)n < 0) + { + if (stream < 0) + { + return; // the connection itself refuses - nothing left to flush + } + stream = -1; // the stream is blocked or finished; keep draining the engine + continue; + } + if (consumed > 0) + { + off += (int)consumed; + } + if (n > 0) + { + _udp.Send(_scratch, (int)n); + } + if (n == 0) + { + if (stream >= 0 && off < payload.Length) + { + stream = -1; + continue; + } + return; + } + } + } + } + + /// Pump until the server's answer has arrived with its fin, and return it. + public string WaitForEcho(int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline && !_echoFin) + { + FlushOut(); + PumpIn(); + } + return Encoding.ASCII.GetString(_echo.ToArray()); + } + + /// + /// Whether the server ended the connection out loud. Receive-only - it sends nothing, so a test + /// can wait here through a server-side idle timeout without keeping the connection alive. + /// + public bool WaitForConnectionClose(int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (!SawConnectionClose && Environment.TickCount64 < deadline) + { + PumpIn(); + } + return SawConnectionClose; + } + + // Drain whatever the engine wants to send (handshake, acks, stream data) to the wire. + private void FlushOut() + { + long consumed; + fixed (byte* dest = _scratch) + { + while (true) + { + nint n = iq_conn_write(_conn, dest, (nuint)_scratch.Length, -1, null, 0, 0, &consumed, NowNs()); + if (n <= 0) + { + break; + } + _udp.Send(_scratch, (int)n); + } + } + } + + // One inbound datagram (or the socket timeout), fed to ngtcp2. DRAINING is the library saying + // the packet carried a CONNECTION_CLOSE. + private void PumpIn() + { + try + { + IPEndPoint? from = null; + byte[] pkt = _udp.Receive(ref from); + DatagramsReceived++; + + fixed (byte* p = pkt) + { + if (iq_conn_read(_conn, null, 0, p, (nuint)pkt.Length, 0, NowNs()) == NgtcpErrDraining) + { + SawConnectionClose = true; + } + } + } + catch (SocketException) + { + // Receive timeout: nothing arrived in this slice, the caller owns the deadline. + } + } + + private static void FillSockaddrIn(Span sa, ushort port) + { + sa.Clear(); + sa[0] = 2; // AF_INET (x86 little-endian: family low byte) + sa[2] = (byte)(port >> 8); + sa[3] = (byte)(port & 0xff); + IPAddress.Loopback.GetAddressBytes().CopyTo(sa[4..]); + } + + [UnmanagedCallersOnly] + private static void OnClientStreamData(void* user, long streamId, byte* data, nuint len, int fin) + { + var self = (TeardownWireClient)GCHandle.FromIntPtr((nint)user).Target!; + self._echo.AddRange(new ReadOnlySpan(data, (int)len).ToArray()); + if (fin != 0) + { + self._echoFin = true; + } + } + + public void Dispose() + { + if (_conn != 0) + { + iq_conn_free(_conn); + _conn = 0; + } + if (_engine != 0) + { + iq_client_engine_free(_engine); + _engine = 0; + } + if (_self.IsAllocated) + { + _self.Free(); + } + _udp.Dispose(); } + + // --- shim client entry points (test-only) --- + + [StructLayout(LayoutKind.Sequential)] + private struct IqCallbacks + { + public delegate* unmanaged OnStreamData; + public delegate* unmanaged OnStreamClose; + public delegate* unmanaged OnHandshakeCompleted; + public delegate* unmanaged OnNewCid; + public delegate* unmanaged OnRetireCid; + public delegate* unmanaged OnStreamReset; + public delegate* unmanaged OnStreamStopSending; + public delegate* unmanaged OnAckedStreamData; + } + + private const string Lib = "ioxide_ngtcp2"; + [DllImport(Lib)] 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(Lib)] private static extern void iq_client_engine_free(nint e); + [DllImport(Lib)] 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(Lib)] private static extern long iq_client_open_bidi(nint conn); + [DllImport(Lib)] 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); + [DllImport(Lib)] private static extern int iq_conn_read(nint conn, void* remoteSa, nuint remoteLen, + byte* pkt, nuint pktLen, byte ecn, ulong ts); + [DllImport(Lib)] private static extern int iq_conn_is_established(nint conn); + [DllImport(Lib)] private static extern void iq_conn_free(nint conn); } diff --git a/tests/Ioxide.Tests.Http/CrossStackParityTests.cs b/tests/Ioxide.Tests.Http/CrossStackParityTests.cs index c2869e69..32e4a507 100644 --- a/tests/Ioxide.Tests.Http/CrossStackParityTests.cs +++ b/tests/Ioxide.Tests.Http/CrossStackParityTests.cs @@ -1,4 +1,6 @@ using ioxide; +using ioxide.ngtcp2; +using ioxide.tls; namespace Ioxide.Tests; @@ -6,16 +8,99 @@ namespace Ioxide.Tests; /// One option, both stacks. Where TCP and QUIC are documented as equivalent and are not. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// The two TLS terminations are deliberately separate - OpenSSL under ioxide.tls, picotls +/// under ioxide.ngtcp2 - and there is a standing rule against a shared base class, so +/// nothing here argues for unifying them. What it looks for is narrower and worse: an option +/// spelled the same on both, described the same on both, that a deployment can set once and get +/// two different behaviours from. That is what makes a configuration reviewed on one port and +/// unsafe on the other. /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// This suite references both stacks, which is why the comparison lives here rather than in +/// Ioxide.Tests.Tls (TCP only) or Ioxide.Tests.E2E (QUIC only). Each stack's own +/// behaviour is covered in its own suite; these tests only ever assert that the two AGREE. /// internal static class CrossStackParityTests { public static void Register(Runner runner) { + runner.Test("rotate/parity: omitting the host table is refused on QUIC and applied on TCP - a known divergence", + () => + { + // Both stacks expose the same operation under the same name, with the same shape, for + // the same reason - an ACME client rewrote the PEM and restarting would be an outage: + // + // TlsService.ReplaceCertificates(TlsCertificate, IReadOnlyDictionary<..>? = null) + // QuicEngine .ReplaceCertificates(QuicCertificate, IReadOnlyDictionary<..>? = null) + // + // The renewal hook anyone writes passes the first argument and stops there, because the + // certificate is what expired. On QUIC that call is refused. On TCP it is applied, and + // applying it REPLACES THE WHOLE TABLE with nothing: every registered name is answered + // by the default certificate from the next handshake on, with no exception and no log + // line. The two stacks give the argument's DEFAULT VALUE opposite meanings. + (string cert, string key) = TestCert.Ensure(); + (string alpha, string alphaKey) = TestCert.EnsureNamed("alpha.test"); + + // ---- QUIC, the stack that already decided this. Also the guard against a vacuous run: + // if the native engine could not load, or the fixtures were wrong, neither of these two + // would behave as stated and the failure below would mean nothing. + using (var engine = new QuicEngine(cert, key, cidLength: 8, alpn: ["h3"])) + { + engine.AddHost("named.test", alpha, alphaKey); + + Assert.Throws( + () => engine.ReplaceCertificates(new QuicCertificate(cert, key)), + "named host"); + + // And the refusal is about the OMISSION, not about rotating a named engine at all: + // saying "no names" out loud is accepted. Without this the assertion above would + // also be satisfied by an engine that refused every rotation it was given. + engine.ReplaceCertificates(new QuicCertificate(cert, key), + new Dictionary()); + } + + // ---- TCP, driven through a real server so the table under test is one that is + // actually being served rather than one that was merely built. + TlsService? service = null; + int port = TestServer.Start(Handlers.TlsSendFirst, r => service = TlsService.Start(r, new TlsOptions + { + CertificatePath = cert, + KeyPath = key, + CertificatesByHost = new Dictionary + { + ["named.test"] = new() { CertificatePath = alpha, KeyPath = alphaKey }, + }, + })); + + // The name has to be live before the rotation, or "the rotation dropped it" is a claim + // about a table that never answered for anything. + Assert.True(Client.ServerCertificateSubject(port, "named.test").Contains("alpha.test"), + "the name should start on its own certificate"); + Assert.True(service!.ServerNames.Count == 1, "and should start being reported"); + + // The divergence, pinned rather than asserted away. Same method name, same omission, + // same default argument - and the two stacks answer differently. QUIC refuses (above); + // TCP APPLIES it, and from here 'named.test' is served CN=localhost. + // + // Neither side is a bug on its own. TCP's behaviour is documented on the parameter ("or + // null for none") and pinned by a passing test - RotationTests, "a name can be dropped + // from a running service" - so the two cannot both be satisfied: raising TCP to QUIC's + // contract is a deliberate breaking change that deletes that test, and it is a decision + // rather than a fix. What this records is that the divergence EXISTS, so that whichever + // way it is resolved, this test has to be rewritten and nobody resolves it by accident. + service.ReplaceCertificates(new TlsCertificate { CertificatePath = cert, KeyPath = key }); + + Assert.True(Client.ServerCertificateSubject(port, "named.test").Contains("localhost"), + "TCP applies an omitted table: the name should fall back to the default certificate"); + Assert.True(service.ServerNames.Count == 0, + "and the name should no longer be reported as served"); + + // And on TCP too, stating the empty table must remain the way to ask for the drop. + service.ReplaceCertificates( + new TlsCertificate { CertificatePath = cert, KeyPath = key }, + new Dictionary()); + + Assert.True(Client.ServerCertificateSubject(port, "named.test").Contains("localhost"), + "with the table emptied on purpose, the name falls back to the default certificate"); + }); } } diff --git a/tests/Ioxide.Tests.Http/TlsClientErrorQueueTests.cs b/tests/Ioxide.Tests.Http/TlsClientErrorQueueTests.cs index f46ca2d1..6d72ac34 100644 --- a/tests/Ioxide.Tests.Http/TlsClientErrorQueueTests.cs +++ b/tests/Ioxide.Tests.Http/TlsClientErrorQueueTests.cs @@ -1,22 +1,354 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Text; using ioxide; +using ioxide.httpclient; namespace Ioxide.Tests; /// /// One pooled upstream connection's failure affecting another, through state they share by being -/// on the same reactor. -/// -/// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// on the same reactor: OpenSSL's error queue belongs to the THREAD, and SSL_get_error consults it +/// BEFORE asking the SSL whether it merely wants more data. +/// +/// The client used to poison that queue on teardown: TlsClientStream.Dispose called SSL_shutdown +/// on a handshake that never finished - which every failed handshake does on its way out - and +/// that call fails and leaves "shutdown while in init" (0A000197) on the reactor's queue. Where +/// the residue lands matters, and it is narrower than the folklore version: /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. -/// +/// - a later HANDSHAKE is immune, because OpenSSL's own state machine calls ERR_clear_error at +/// entry - verified against this box's libssl 3.0.13, and end to end against the pre-fix +/// build, where every retry still failed with its own error and never the residue; +/// - an ESTABLISHED connection is not: its next SSL_read that merely needs more bytes is +/// classified from the queue first, so the residue turns "want read" into a fatal +/// SSL_ERROR_SSL and a healthy pooled connection dies. Verified against libssl directly: +/// the same blocked read classifies 2 (WANT_READ) on a clean queue and 1 (SSL_ERROR_SSL) +/// with only the residue planted. +/// +/// So the regression pin here drives the real victim: an established connection that must survive +/// a neighbouring connection's failed-handshake teardown. The recovery tests around it pin the +/// rest of the behaviour and say what they do NOT pin. +/// internal static class TlsClientErrorQueueTests { public static void Register(Runner runner) { + runner.Test("tls client: another connection's failed handshake does not kill an established connection on the same reactor", () => + { + // The origin serves its FIRST accept properly and answers every later handshake with + // bytes that are not TLS. A pool of two on the single test reactor then holds exactly + // one established connection while its second opener fails, is torn down, and is + // retried behind the backoff gate - a failed-handshake teardown every few hundred + // milliseconds on the thread whose error queue the established connection shares. + using var origin = new FlakyTlsOrigin { Mode = FlakyTlsOrigin.OriginMode.Sabotage, ServeFirstAccept = true }; + int proxy = StartProxy(origin.Port, poolSize: 2); + + (int status, string body) = Client.Get(proxy, "/warm", timeoutMs: 30_000); + Assert.Equal(200, status); + Assert.True(body == "200|hello over TLS", + $"the served connection should be established and answering, got: {body}"); + + // Require a FRESH sabotaged handshake after that response, so its teardown runs while + // the established connection sits idle - not before the connection existed, when the + // next handshake's own entry into OpenSSL would have wiped the queue anyway. + int seen = origin.SabotagedHandshakes; + WaitUntil(() => origin.SabotagedHandshakes > seen, + "the pool stopped retrying its failing second connection, so no failed-handshake " + + "teardown ran beside the established connection and this test proved nothing"); + + // The established connection's next response read begins with an SSL_read on an empty + // buffer - a classification that happens BEFORE any socket recv - so with the old + // teardown this request died as "SSL_read failed (error 1): ... shutdown while in + // init" without the origin misbehaving on this connection at all. + (status, body) = Client.Get(proxy, "/after-poison", timeoutMs: 30_000); + Assert.Equal(200, status); + Assert.True(body == "200|hello over TLS", + $"the established connection must survive its neighbour's teardown, got: {body}"); + }); + + runner.Test("tls client: after every handshake to an origin failed, a later connect on the same reactor succeeds", () => + { + // Recovery, pinned deliberately: repeated failed handshakes (and their teardowns) must + // leave the reactor able to connect the moment the origin behaves. NOTE what this does + // not pin: it stayed green even against the pre-fix teardown, because a handshake + // self-clears the queue on entry - the test above is the one that discriminates. + using var origin = new FlakyTlsOrigin { Mode = FlakyTlsOrigin.OriginMode.Sabotage }; + int proxy = StartProxy(origin.Port, poolSize: 1); + + (int status, string body) = Client.Get(proxy, "/poison", timeoutMs: 30_000); + Assert.Equal(200, status); + Assert.True(body.StartsWith("599|"), $"the sabotaged handshake should have failed, got: {body}"); + Assert.True(body.Contains("TLS handshake to 'localhost' failed"), + $"the failure should be the TLS handshake itself, not a refused connect or a bare timeout, got: {body}"); + Assert.True(origin.SabotagedHandshakes >= 1, + "the origin never sabotaged a handshake, so no teardown ran and this test proved nothing"); + + origin.Mode = FlakyTlsOrigin.OriginMode.Serve; + (status, body) = Client.Get(proxy, "/after", timeoutMs: 30_000); + Assert.Equal(200, status); + Assert.True(body == "200|hello over TLS", + $"a fresh connection after the origin recovered must succeed, got: {body}"); + }); + + runner.Test("tls client: a connection killed mid-session is replaced cleanly on the same reactor", () => + { + // The other entry into the same teardown: a handshake that COMPLETED and then died on + // a fatal record. OpenSSL flips such a session back to "in init" when it errors + // (verified against libssl 3.0.13), so an unconditional SSL_shutdown in Dispose queues + // the same residue here - this is the case a guard of the form "the handshake + // finished, so shutdown is safe" would miss. Like the recovery test above, the + // replacement HANDSHAKE could not be poisoned even pre-fix; this pins that the pool + // discards the corpse and the reactor keeps serving. + using var origin = new FlakyTlsOrigin { Mode = FlakyTlsOrigin.OriginMode.Serve }; + int proxy = StartProxy(origin.Port, poolSize: 1); + + origin.ArmInjection(); + (int status, string body) = Client.Get(proxy, "/kill", timeoutMs: 30_000); + Assert.Equal(200, status); + Assert.True(body.StartsWith("599|"), $"the injected garbage should have failed the request, got: {body}"); + Assert.True(body.Contains("SSL_read failed"), + $"the failure should be a fatal TLS record on an established session, got: {body}"); + Assert.True(origin.InjectedSessions == 1, + "the origin never injected garbage into an established session, so this test proved nothing"); + + (status, body) = Client.Get(proxy, "/replaced", timeoutMs: 30_000); + Assert.Equal(200, status); + Assert.True(body == "200|hello over TLS", + $"the pool must replace the killed connection and serve again, got: {body}"); + }); + } + + private static void WaitUntil(Func condition, string orElse) + { + long deadlineMs = Environment.TickCount64 + 20_000; + while (!condition()) + { + Assert.True(Environment.TickCount64 < deadlineMs, orElse); + Thread.Sleep(50); + } + } + + // A TCP endpoint whose handler fetches from the flaky TLS origin through the pooled client and + // writes back "|" - the shape TlsClientTests uses. The TestServer + // reactor is single (the harness stamps ReactorCount = 1), so every pooled upstream connection + // shares one thread, which is what makes its OpenSSL error queue shared state. + private static int StartProxy(int originPort, int poolSize) + { + (string certPath, _) = TestCert.Ensure(); + + TlsClientContext tls = TlsClientContext.Create(new TlsClientOptions + { + ServerName = "localhost", + AlpnProtocols = ["http/1.1"], + CaFile = certPath, // the origin's cert is self-signed, so it is its own root + }); + + var options = new HttpClientOptions + { + Host = "127.0.0.1", + Port = (ushort)originPort, + PoolSize = poolSize, + AcquireTimeoutMs = 4_000, + Tls = tls, + }; + + return TestServer.Start(ProxyHandler, onStart: reactor => HttpClientPool.Start(reactor, options)); + } + + private static async Task ProxyHandler(Reactor reactor, TcpConnection connection) + { + try + { + HttpClientPool upstream = reactor.GetService()!; + + while (true) + { + RecvSnapshot snapshot = await connection.ReadAsync(); + if (snapshot.IsClosed) + { + return; + } + string path = Wire.ReadPath(connection, snapshot); + + string detail; + int status; + try + { + using HttpClientResponse response = await upstream.GetAsync(path); + status = response.Status; + detail = Encoding.ASCII.GetString(response.Body.Span); + } + catch (Exception e) + { + status = 599; + detail = e.Message; + } + + Wire.Write(connection, 200, $"{status}|{detail}"); + await connection.FlushAsync(); + connection.ResetRead(); + } + } + finally + { + connection.DecRef(); + } + } + + /// + /// A TLS origin whose behaviour the test controls per accept, so one pool aimed at one port + /// can hold an established connection while its neighbours fail. Serve mode is SslStream, like + /// TlsTestOrigin - an independent implementation, so agreement means more than agreeing with + /// ourselves. + /// + private sealed class FlakyTlsOrigin : IDisposable + { + public enum OriginMode + { + /// Answer the ClientHello with bytes that are not TLS, then close. + Sabotage, + + /// Behave: handshake and serve one small response per request. + Serve, + } + + private readonly TcpListener _listener; + private readonly X509Certificate2 _certificate; + private readonly CancellationTokenSource _stopping = new(); + private int _accepts; + private int _sabotagedHandshakes; + private int _injectedSessions; + private int _injectionArmed; + + // Written by the test thread between phases, read by the accept loop. + private volatile OriginMode _mode; + public OriginMode Mode { get => _mode; set => _mode = value; } + + /// Serve the first accept regardless of , so a pool can hold + /// one established connection while every later handshake is sabotaged. + public bool ServeFirstAccept { get; init; } + + public int Port { get; } + + /// Handshakes answered with non-TLS bytes - the proof a failed-handshake + /// teardown actually ran on the client. + public int SabotagedHandshakes => Volatile.Read(ref _sabotagedHandshakes); + + /// Established sessions killed by an injected raw record. + public int InjectedSessions => Volatile.Read(ref _injectedSessions); + + /// The next request received on ANY established session is answered with garbage + /// written under the TLS layer, exactly once, so the client's SSL_read fails fatally. + public void ArmInjection() => Interlocked.Exchange(ref _injectionArmed, 1); + + public FlakyTlsOrigin() + { + (string certPath, string keyPath) = TestCert.Ensure(); + X509Certificate2 certificate = X509Certificate2.CreateFromPemFile(certPath, keyPath); + + // SslStream on Linux needs the private key associated through a PFX round-trip; a + // PEM-built certificate carries the key in a form AuthenticateAsServerAsync won't use. + _certificate = X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null); + + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + + _ = AcceptLoopAsync(); + } + + private async Task AcceptLoopAsync() + { + while (!_stopping.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(_stopping.Token); + } + catch + { + return; // stopped + } + + bool serve = _mode == OriginMode.Serve + || (ServeFirstAccept && Interlocked.Increment(ref _accepts) == 1); + _ = ServeAsync(client, serve); + } + } + + private async Task ServeAsync(TcpClient client, bool serve) + { + using (client) + { + SslStream? tls = null; + try + { + NetworkStream raw = client.GetStream(); + + if (!serve) + { + // Not a bare close: a first byte that cannot be a TLS content type makes + // SSL_connect fail deterministically, where a close can surface as either + // FIN or RST depending on what was left unread. + await raw.WriteAsync("GARBAGE, NOT A TLS RECORD\r\n"u8.ToArray(), _stopping.Token); + Interlocked.Increment(ref _sabotagedHandshakes); + return; + } + + tls = new SslStream(raw, leaveInnerStreamOpen: false); + await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = _certificate, + ApplicationProtocols = [new SslApplicationProtocol("http/1.1")], + }); + + var request = new byte[8192]; + while (true) + { + int n = await tls.ReadAsync(request, _stopping.Token); + if (n == 0) + { + return; // peer closed + } + + if (Interlocked.Exchange(ref _injectionArmed, 0) == 1) + { + // Under the TLS layer, so the client's record layer sees it raw. + await raw.WriteAsync("GARBAGE, NOT A TLS RECORD\r\n"u8.ToArray(), _stopping.Token); + Interlocked.Increment(ref _injectedSessions); + return; + } + + const string body = "hello over TLS"; + byte[] response = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\n" + + $"content-length: {body.Length}\r\n" + + "content-type: text/plain\r\n\r\n" + + body); + await tls.WriteAsync(response, _stopping.Token); + } + } + catch + { + // A client that walked away from a sabotaged exchange is the point here, so a + // failure is data rather than an error to report. + } + finally + { + tls?.Dispose(); + } + } + } + + public void Dispose() + { + _stopping.Cancel(); + _listener.Stop(); + _certificate.Dispose(); + _stopping.Dispose(); + } } } diff --git a/tests/Ioxide.Tests.Http/TlsClientPostureTests.cs b/tests/Ioxide.Tests.Http/TlsClientPostureTests.cs index b9b77a3d..57d842a2 100644 --- a/tests/Ioxide.Tests.Http/TlsClientPostureTests.cs +++ b/tests/Ioxide.Tests.Http/TlsClientPostureTests.cs @@ -1,21 +1,462 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Text; using ioxide; +using ioxide.httpclient; namespace Ioxide.Tests; /// -/// The client's own posture knobs: protocol floor, ALPN, and where its trust comes from. +/// The client's own posture knobs: protocol floor, ALPN, and where its trust comes from. Each test +/// asserts the option TOOK EFFECT on the wire - a peer outside the stated posture is turned away +/// while one inside it is served - not that setting it was accepted. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// Two live defects ride as Pending: /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// - BuildAlpnWire casts each UTF-16 unit to a byte, so a non-ASCII protocol name is OFFERED as +/// a different protocol made of its chars' low bytes - the client twin of the server-side +/// defect in TlsService.BuildAlpnWire, proven there today; +/// - HandshakeTimeoutMs is only checked between handshake flights, so the one peer a handshake +/// timeout exists for - accepted the connect, then went silent - is not bounded by it at all. +/// +/// Examined without a failing result, stated so they are not re-litigated: MinimumVersion = 0 is +/// accepted (OpenSSL's "no floor"), but this box's system-wide MinProtocol makes the weakened +/// floor unobservable, so no test can discriminate it here; a ServerName over 255 bytes makes the +/// unchecked SNI ctrl silently send no SNI, but SSL_set1_host still checks the certificate against +/// the full name, so every reachable outcome fails safe; and the client has no in-memory CA +/// source, so the file/in-memory equivalence question the server answers cannot arise. /// internal static class TlsClientPostureTests { public static void Register(Runner runner) { + runner.Test("tls client: a TLS 1.3 floor refuses a 1.2-capped origin and still serves a 1.3 one", () => + { + // The defect this pins was real and is fixed: MinimumVersion went to SSL_CTX_ctrl and + // the return was DISCARDED, so a floor OpenSSL did not apply was silently the default. + // Nothing end-to-end held the fix in place; this does, from the wire. + (string certPath, string keyPath) = TestCert.Ensure(); + using var capped = new PosturedOrigin(certPath, keyPath, SslProtocols.Tls12); + + // Control first, on the DEFAULT floor (1.2): served, and the origin's body names + // Tls12, which proves the cap on the origin is real - without this, the refusal below + // could be any broken origin. + int control = StartProxy(capped.Port, new TlsClientOptions + { + ServerName = "localhost", + AlpnProtocols = ["http/1.1"], + CaFile = certPath, + }); + (int status, string body) = Client.Get(control, "/floor-control", timeoutMs: 20_000); + Assert.Equal(200, status); + Assert.Equal("200|hello over Tls12", body); + + // The ONLY change from the control is the floor. The 1.2-capped origin must now be + // refused, and for the version - not a certificate or a timeout. + int pinned = StartProxy(capped.Port, new TlsClientOptions + { + ServerName = "localhost", + AlpnProtocols = ["http/1.1"], + CaFile = certPath, + MinimumVersion = OpenSslVersions.Tls13, + }); + (status, body) = Client.Get(pinned, "/floor-pinned", timeoutMs: 20_000); + Assert.Equal(200, status); // the proxy answers; the upstream outcome is in the body + Assert.True(body.StartsWith("599|"), + $"a TLS 1.3 floor should refuse an origin capped at 1.2, got: {body}"); + Assert.True(body.Contains("protocol"), + $"the refusal should name the protocol version, got: {body}"); + + // And the floor is a floor, not a breaker: an origin that can speak 1.3 is served, + // at 1.3. + using var modern = new PosturedOrigin(certPath, keyPath, SslProtocols.None); + int strict = StartProxy(modern.Port, new TlsClientOptions + { + ServerName = "localhost", + AlpnProtocols = ["http/1.1"], + CaFile = certPath, + MinimumVersion = OpenSslVersions.Tls13, + }); + (status, body) = Client.Get(strict, "/floor-modern", timeoutMs: 20_000); + Assert.Equal(200, status); + Assert.Equal("200|hello over Tls13", body); + }); + + runner.Test("tls client: a MinimumVersion OpenSSL will not accept fails Create, not silently the default floor", () => + { + // MinimumVersion is a bare int. For a value OpenSSL does not recognise, ssl3_ctx_ctrl + // returns 0, applies NOTHING and queues NO error - so before the return was checked, a + // typo'd floor meant OpenSSL's default floor with no way to find out. The refusal has + // to be loud and has to name the call. + Assert.Throws(() => + { + using TlsClientContext _ = TlsClientContext.Create(new TlsClientOptions + { + ServerName = "localhost", + MinimumVersion = 0x0305, // one past TLS 1.3 - the off-by-one typo shape + }); + }, because: "set_min_proto_version"); + + Assert.Throws(() => + { + using TlsClientContext _ = TlsClientContext.Create(new TlsClientOptions + { + ServerName = "localhost", + MinimumVersion = 0x0034, // 0x0304 with a dropped digit + }); + }, because: "set_min_proto_version"); + + // The control for both is every other test in this file constructing a context with a + // documented version and being served. + }); + + runner.Test("tls client: trust is exactly the CaFile - a chain it anchors is served, one it does not is refused", () => + { + // The existing suite only ever trusts a SELF-SIGNED origin through CaFile (the leaf is + // its own anchor), and only refuses against the SYSTEM store. This pins the other two + // corners: a real CA -> leaf chain verifies through CaFile, and a well-formed chain + // anchored OUTSIDE the file is refused - CaFile decides, not whatever else is lying + // around. + (string ca, string cert, string key) = TestCert.EnsureNamedFromCa("localhost"); + using var origin = new PosturedOrigin(cert, key, SslProtocols.None); + + int inside = StartProxy(origin.Port, new TlsClientOptions + { + ServerName = "localhost", + AlpnProtocols = ["http/1.1"], + CaFile = ca, + }); + (int status, string body) = Client.Get(inside, "/ca-inside", timeoutMs: 20_000); + Assert.Equal(200, status); + Assert.True(body.StartsWith("200|hello over "), + $"a chain anchored in CaFile should verify, got: {body}"); + + // Same origin, and the ONLY change is the anchor: a trust file that never signed this + // chain. Refused for verification, not for a connect or a timeout. + (string stranger, _) = TestCert.Ensure(); + int outside = StartProxy(origin.Port, new TlsClientOptions + { + ServerName = "localhost", + AlpnProtocols = ["http/1.1"], + CaFile = stranger, + }); + (status, body) = Client.Get(outside, "/ca-outside", timeoutMs: 20_000); + Assert.Equal(200, status); + Assert.True(body.StartsWith("599|"), + $"a chain anchored outside CaFile should be refused, got: {body}"); + Assert.True(body.Contains("certificate verify failed"), + $"should name the verification failure, got: {body}"); + }); + + runner.Pending("tls client: a non-ascii alpn protocol must not be offered as a different protocol", () => + { + // TlsClientContext.BuildAlpnWire writes (byte)protocol[i] - the LOW BYTE of each UTF-16 + // unit. U+0168 has low byte 0x68, 'h', so a client configured to offer ONLY + // "Ũ" + "2" puts the exact bytes "h2" on the wire, the origin selects h2, and + // NegotiatedAlpn reports a protocol the caller never configured. The server side has + // the same cast and the same proven defect; this is the client half. + string exotic = "Ũ" + "2"; + + using TlsTestOrigin origin = TlsTestOrigin.Start("h2"); // speaks ONLY h2 + (string certPath, _) = TestCert.Ensure(); + + // Control: a genuine h2 offer against this origin negotiates h2 and converses. So if + // the exotic probe below also lands "hello over h2", it is the cast - not environment. + int control = StartProxy(origin.Port, new TlsClientOptions + { + ServerName = "localhost", + AlpnProtocols = ["h2"], + CaFile = certPath, + }); + (int status, string body) = Client.Get(control, "/alpn-control", timeoutMs: 20_000); + Assert.Equal(200, status); + Assert.Equal("200|hello over h2", body); + + int proxy; + try + { + proxy = StartProxy(origin.Port, new TlsClientOptions + { + ServerName = "localhost", + AlpnProtocols = [exotic], + CaFile = certPath, + }); + } + catch (Exception e) when (e is ArgumentException || e.Message.Contains("ALPN") || e.Message.Contains('Ũ')) + { + // Refusing the configuration loudly is the other acceptable resolution. Narrow on + // purpose: an unrelated failure to start must stay a failure, not a quiet pass. + return; + } + + (status, body) = Client.Get(proxy, "/alpn-exotic", timeoutMs: 20_000); + Assert.Equal(200, status); + Assert.True(!body.EndsWith("hello over h2"), + "a client configured to offer only 'U+0168 2' completed a conversation the origin " + + $"negotiated as h2; body: {body}, origin saw alpn={origin.LastAlpn ?? "none"}"); + }, "BuildAlpnWire casts UTF-16 units to bytes, so the configured \"\\u0168 2\" goes on the " + + "wire as the bytes 'h2' and the client negotiates - and NegotiatedAlpn reports - a " + + "protocol nobody configured"); + + runner.Pending("tls client: HandshakeTimeoutMs bounds a handshake whose peer accepted and went silent", () => + { + // The option says "how long the handshake may take before the connect fails", but + // RunHandshakeAsync checks its deadline only BETWEEN flights. A peer that accepts the + // TCP connect and then never answers the ClientHello - the one peer a handshake + // timeout exists for - leaves the connect parked in RecvAsync, where the deadline is + // never consulted. Through the pool that surfaces as the ACQUIRE timeout with no + // reason (the open never failed - it is still parked); for a direct + // TlsClientContext.ConnectAsync caller there is no second timeout, and the await + // simply never completes. + using var tarpit = new TarpitOrigin(); + (string certPath, _) = TestCert.Ensure(); + + int proxy = StartProxy(tarpit.Port, new TlsClientOptions + { + ServerName = "localhost", + AlpnProtocols = ["http/1.1"], + CaFile = certPath, + HandshakeTimeoutMs = 750, + }, acquireTimeoutMs: 4_000); + + (int status, string body) = Client.Get(proxy, "/tarpit", timeoutMs: 20_000); + Assert.Equal(200, status); + Assert.True(body.StartsWith("599|"), + $"nothing should have been served by an origin that never answered, got: {body}"); + + // Guard against passing vacuously: the tarpit really was reached and held the socket. + Assert.True(tarpit.Accepted > 0, "the connect never reached the tarpit, so nothing was proven"); + + // The claim: the failure the caller sees names the handshake deadline (the message the + // dribbling-peer path already produces). Today it is the pool's own acquire timeout + // with no cause attached, because the open is still parked when it fires. + Assert.True(body.Contains("did not complete within"), + $"the handshake deadline should have fired and been named, got: {body}"); + }, "the deadline is only checked between handshake flights, so a peer that accepts and " + + "goes silent parks the connect in RecvAsync forever - HandshakeTimeoutMs never fires, " + + "and only the pool's acquire timeout (which a direct ConnectAsync caller does not have) " + + "bounds it"); + } + + // --------------------------------------------------------------------------------------------- + + /// + /// An SslStream origin like , with the two knobs these tests need + /// that it does not offer: the certificate to serve, and a protocol-version cap. The response + /// body names the negotiated version, so a test can assert which TLS actually ran rather than + /// that a handshake happened. + /// + private sealed class PosturedOrigin : IDisposable + { + private readonly TcpListener _listener; + private readonly X509Certificate2 _certificate; + private readonly SslProtocols _protocols; + private readonly CancellationTokenSource _stopping = new(); + + public int Port { get; } + + public PosturedOrigin(string certPath, string keyPath, SslProtocols protocols) + { + using X509Certificate2 pem = X509Certificate2.CreateFromPemFile(certPath, keyPath); + // SslStream on Linux needs the key associated through a PFX round-trip. + _certificate = X509CertificateLoader.LoadPkcs12(pem.Export(X509ContentType.Pfx), null); + _protocols = protocols; + + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _ = AcceptLoopAsync(); + } + + private async Task AcceptLoopAsync() + { + while (!_stopping.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(_stopping.Token); + } + catch + { + return; // stopped + } + + _ = ServeAsync(client); + } + } + + private async Task ServeAsync(TcpClient client) + { + using (client) + { + SslStream? tls = null; + try + { + tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false); + await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = _certificate, + ApplicationProtocols = [new SslApplicationProtocol("http/1.1")], + EnabledSslProtocols = _protocols, + }); + + var request = new byte[8192]; + while (true) + { + int n = await tls.ReadAsync(request, _stopping.Token); + if (n == 0) + { + return; // peer closed + } + + string body = $"hello over {tls.SslProtocol}"; + byte[] response = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\n" + + $"content-length: {body.Length}\r\n" + + "content-type: text/plain\r\n\r\n" + + body); + await tls.WriteAsync(response, _stopping.Token); + } + } + catch + { + // A refused handshake is the point of several tests; failure here is data. + } + finally + { + tls?.Dispose(); + } + } + } + + public void Dispose() + { + _stopping.Cancel(); + _listener.Stop(); + _certificate.Dispose(); + _stopping.Dispose(); + } + } + + /// + /// The peer that accepts and then says nothing, ever: no ServerHello, no close. What a stalled + /// middlebox or a wedged origin looks like, and the case a handshake timeout exists for. + /// + private sealed class TarpitOrigin : IDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _stopping = new(); + private readonly List _held = []; + + public int Port { get; } + public int Accepted; + + public TarpitOrigin() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _ = AcceptLoopAsync(); + } + + private async Task AcceptLoopAsync() + { + while (!_stopping.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(_stopping.Token); + } + catch + { + return; // stopped + } + + lock (_held) + { + _held.Add(client); // held open and never read: the peer went silent, not away + } + Interlocked.Increment(ref Accepted); + } + } + + public void Dispose() + { + _stopping.Cancel(); + _listener.Stop(); + lock (_held) + { + foreach (TcpClient client in _held) + { + client.Dispose(); + } + } + _stopping.Dispose(); + } + } + + // A TCP endpoint whose handler fetches from the TLS origin through the pooled client and + // writes back "|" - the same shape TlsClientTests uses, duplicated + // here because its copy is private to that file. + private static int StartProxy(int originPort, TlsClientOptions tlsOptions, int acquireTimeoutMs = 5_000) + { + TlsClientContext tls = TlsClientContext.Create(tlsOptions); + + var options = new HttpClientOptions + { + Host = "127.0.0.1", + Port = (ushort)originPort, + PoolSize = 1, + AcquireTimeoutMs = acquireTimeoutMs, + Tls = tls, + }; + + return TestServer.Start(ProxyHandler, onStart: reactor => HttpClientPool.Start(reactor, options)); + } + + private static async Task ProxyHandler(Reactor reactor, TcpConnection connection) + { + try + { + HttpClientPool upstream = reactor.GetService()!; + + while (true) + { + RecvSnapshot snapshot = await connection.ReadAsync(); + if (snapshot.IsClosed) + { + return; + } + string path = Wire.ReadPath(connection, snapshot); + + string detail; + int status; + try + { + using HttpClientResponse response = await upstream.GetAsync(path); + status = response.Status; + detail = Encoding.ASCII.GetString(response.Body.Span); + } + catch (Exception e) + { + status = 599; + detail = e.Message; + } + + Wire.Write(connection, 200, $"{status}|{detail}"); + await connection.FlushAsync(); + connection.ResetRead(); + } + } + finally + { + connection.DecRef(); + } } } diff --git a/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs b/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs index a73575e4..2a7d8646 100644 --- a/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs +++ b/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs @@ -1,4 +1,11 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; using ioxide; +using ioxide.httpclient; namespace Ioxide.Tests; @@ -7,16 +14,594 @@ namespace Ioxide.Tests; /// rather than a name, and the chain. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// This is the one place in the repo where ioxide is the party being PROTECTED rather than the +/// party protecting, so a gap here is a gap in every outbound call a user makes. Every case here +/// drives the real pooled client against an independent server (the BCL's SslStream) holding a +/// certificate minted for that case, so a pass means our verification agrees with somebody else's +/// idea of the certificate rather than only with itself. /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// The certificates are minted here rather than through TestCert because each one is deliberately +/// malformed in a different way - a partial wildcard, an address instead of a name, a window that +/// has closed - and a fixture cache exists to hand out the clean shapes. /// internal static class TlsClientVerificationTests { public static void Register(Runner runner) { + RegisterWildcards(runner); + RegisterAddresses(runner); + RegisterChain(runner); + } + + /// + /// Wildcard matching, which the client never spells out: it calls SSL_set1_host and inherits + /// whatever OpenSSL's default hostflags mean this month. + /// + private static void RegisterWildcards(Runner runner) + { + runner.Pending("tls client: a partial wildcard must not match (ww*.example.com is not www.example.com)", () => + { + // A certificate issued for 'ww*.example.com' is a certificate for a PREFIX, and the + // holder of one for 'ww*' can speak for www, wwx and every other name that starts the + // same way. Nobody issues these deliberately; the reason it matters is that a name + // constraint or an issuance policy written in terms of whole labels does not see them. + using Authority ca = Authority.Mint(); + using X509Certificate2 leaf = ca.Leaf("CN=partial wildcard origin", dns: ["ww*.example.com"]); + using Origin origin = Origin.Start(leaf); + + string body = Fetch(origin.Port, "www.example.com", ca.PemPath); + + Assert.True(!body.Contains("verified"), + $"a partial wildcard should not have authenticated www.example.com, got: {body}"); + Assert.True(body.Contains("certificate verify failed"), + $"should be refused as a verification failure, got: {body}"); + }, "SSL_set1_host runs with OpenSSL's default hostflags: X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS " + + "is never set, so 'ww*' matches 'www'. curl sets it, and SslStream and Python refuse " + + "partial wildcards outright"); + + runner.Test("tls client: control - a full wildcard matches one label below it", () => + { + // The control for the case above, and the reason it is a control: the ONLY difference + // is the two characters in front of the star. A '*.example.com' certificate is + // ordinary and has to keep working, so the pending test above cannot be satisfied by + // refusing wildcards altogether. + using Authority ca = Authority.Mint(); + using X509Certificate2 leaf = ca.Leaf("CN=wildcard origin", dns: ["*.example.com"]); + using Origin origin = Origin.Start(leaf); + + Assert.Equal("200|verified", Fetch(origin.Port, "www.example.com", ca.PemPath)); + }); + + runner.Test("tls client: a wildcard does not match across a label", () => + { + // '*.example.com' covers one label, not a subtree - so it must not authenticate + // a.b.example.com. Pinned because the obvious wrong fix for the pending case above is + // to reach for the hostflags argument and pass the wrong constant: + // X509_CHECK_FLAG_MULTI_LABEL_WILDCARDS is right next to the one that is wanted and + // widens matching instead of narrowing it. + using Authority ca = Authority.Mint(); + using X509Certificate2 leaf = ca.Leaf("CN=wildcard origin", dns: ["*.example.com"]); + using Origin origin = Origin.Start(leaf); + + string body = Fetch(origin.Port, "a.b.example.com", ca.PemPath); + Assert.True(body.Contains("certificate verify failed"), + $"a single-label wildcard should not span two labels, got: {body}"); + }); + } + + /// + /// An origin reached by its address rather than by a name. The client only ever calls + /// SSL_set1_host, never SSL_set1_ip_asc - these pin that this is nonetheless correct, because + /// OpenSSL 3's SSL_set1_host routes a literal address to the IP parameter itself. + /// + private static void RegisterAddresses(Runner runner) + { + runner.Test("tls client: an origin reached by its address is verified against the iPAddress SAN", () => + { + // No dNSName at all, and a subject that is deliberately NOT the address: the only + // thing in this certificate that can authenticate 127.0.0.1 is the iPAddress SAN, so a + // pass means that SAN was the thing consulted, not a CN fallback. + using Authority ca = Authority.Mint(); + using X509Certificate2 leaf = ca.Leaf("CN=ioxide address origin", ips: [IPAddress.Loopback]); + using Origin origin = Origin.Start(leaf); + + Assert.Equal("200|verified", Fetch(origin.Port, "127.0.0.1", ca.PemPath)); + }); + + runner.Test("tls client: a dNSName holding the literal address does not authenticate that address", () => + { + // The other half, and the half that says the address is treated AS an address: this + // certificate carries the text '127.0.0.1' as a dNSName and no iPAddress SAN. A + // hostname comparison would match it string-for-string. It must not. + using Authority ca = Authority.Mint(); + using X509Certificate2 leaf = ca.Leaf("CN=ioxide address origin", dns: ["127.0.0.1"]); + using Origin origin = Origin.Start(leaf); + + string body = Fetch(origin.Port, "127.0.0.1", ca.PemPath); + Assert.True(body.Contains("certificate verify failed"), + $"a dNSName of the literal address must not authenticate it, got: {body}"); + }); + } + + /// + /// The ordinary matrix - expired, an anchor we were not given, self-signed, an intermediate the + /// origin did not send - asserted on the REASON rather than only on the refusal. + /// + private static void RegisterChain(Runner runner) + { + runner.Test("tls client: an expired server certificate is refused as a verification failure", () => + { + // Correctly signed by an anchor the client trusts, correct name, and its window closed + // yesterday. It sits inside the CA's own window because .NET will not issue a leaf that + // starts before its issuer does. + using Authority ca = Authority.Mint(); + using X509Certificate2 leaf = ca.Leaf("CN=expired origin", dns: [OriginName], + notBefore: DateTimeOffset.UtcNow.AddDays(-10), notAfter: DateTimeOffset.UtcNow.AddDays(-1)); + using Origin origin = Origin.Start(leaf); + + string body = Fetch(origin.Port, OriginName, ca.PemPath); + Assert.True(body.Contains("certificate verify failed"), + $"an expired certificate should be refused as a verification failure, got: {body}"); + }); + + runner.Test("tls client: an anchor the client was not given is refused as a verification failure", () => + { + // Valid, in date, and issued by a CA that is simply not the one this client was + // configured with. Distinct from the existing untrusted-certificate test, which checks + // a self-signed leaf against the system store. + using Authority ca = Authority.Mint(); + using Authority stranger = Authority.Mint(); + using X509Certificate2 leaf = ca.Leaf("CN=good origin", dns: [OriginName]); + using Origin origin = Origin.Start(leaf); + + string body = Fetch(origin.Port, OriginName, stranger.PemPath); + Assert.True(body.Contains("certificate verify failed"), + $"an unknown issuer should be refused as a verification failure, got: {body}"); + }); + + runner.Test("tls client: a self-signed server certificate is refused as a verification failure", () => + { + using Authority ca = Authority.Mint(); + using X509Certificate2 leaf = SelfSigned("CN=self-signed origin", OriginName); + using Origin origin = Origin.Start(leaf); + + string body = Fetch(origin.Port, OriginName, ca.PemPath); + Assert.True(body.Contains("certificate verify failed"), + $"a self-signed certificate should be refused as a verification failure, got: {body}"); + }); + + runner.Test("tls client: a chain missing its intermediate is refused as a verification failure", () => + { + // root -> intermediate -> leaf, with the origin sending the leaf alone. The client + // holds the root, so every signature in the chain is one it could verify - it just + // cannot get from the leaf to the root without a certificate nobody sent it. This is + // the failure that looks like "works on my machine", because a client whose store + // happens to hold the intermediate is served. + using Authority ca = Authority.Mint(); + using Authority intermediate = ca.Intermediate("CN=ioxide test intermediate"); + using X509Certificate2 leaf = intermediate.Leaf("CN=deep origin", dns: [OriginName]); + using Origin origin = Origin.Start(leaf); + + string body = Fetch(origin.Port, OriginName, ca.PemPath); + Assert.True(body.Contains("certificate verify failed"), + $"an incomplete chain should be refused as a verification failure, got: {body}"); + + // The control, on the same origin and the same bytes on the wire: trusting the + // intermediate directly completes the chain. So the refusal above was about the gap in + // the chain and not about anything else being wrong with the leaf. + using PemFile bundle = PemFile.Write(ca.Certificate, intermediate.Certificate); + Assert.Equal("200|verified", Fetch(origin.Port, OriginName, bundle.Path)); + }); + + runner.Test("tls client: a dropped connection is not reported as a verification failure", () => + { + // The control for every assertion above. 'certificate verify failed' only means + // something if a connection that fails for an unrelated reason does NOT say it - and + // the caller's whole ability to act on a certificate problem rests on telling the two + // apart. + using Origin origin = Origin.StartClosingImmediately(); + using Authority ca = Authority.Mint(); + + string body = Fetch(origin.Port, OriginName, ca.PemPath); + Assert.True(body.StartsWith("599|"), $"the connection should have failed, got: {body}"); + Assert.True(!body.Contains("certificate verify failed"), + $"a dropped connection must not be reported as a certificate problem, got: {body}"); + + // And it has to name what DID happen, or this control would be satisfied by any + // failure at all - including the proxy never reaching the origin. + Assert.True(body.Contains("closed during the TLS handshake"), + $"should say the connection closed, got: {body}"); + }); + + runner.Pending("tls client: a verification failure says which check failed", () => + { + // Expired, self-signed and unknown-issuer are three different X509 verify codes (10, 18 + // and 20), and a caller acts on them differently: one is a clock or a renewal, one is + // an origin misconfigured, one is trust configured wrongly on OUR side. All three + // arrive as the same sentence. + using Authority trusted = Authority.Mint(); + using Authority stranger = Authority.Mint(); + + string expired = Refusal(trusted.PemPath, trusted.Leaf("CN=expired origin", dns: [OriginName], + notBefore: DateTimeOffset.UtcNow.AddDays(-10), notAfter: DateTimeOffset.UtcNow.AddDays(-1))); + string selfSigned = Refusal(trusted.PemPath, SelfSigned("CN=self-signed origin", OriginName)); + string unknownIssuer = Refusal(trusted.PemPath, stranger.Leaf("CN=good origin", dns: [OriginName])); + + // Vacuity guard: this must be three refusals that all reached verification, or + // "they differ" would be satisfied by three unrelated failures. + foreach (string body in new[] { expired, selfSigned, unknownIssuer }) + { + Assert.True(body.Contains("certificate verify failed"), + $"expected a verification failure, got: {body}"); + } + + // Compared from the handshake message onwards, because the pool's own prefix carries + // the port and would make three identical reasons look different. + string[] reasons = [Reason(expired), Reason(selfSigned), Reason(unknownIssuer)]; + Assert.True(reasons.Distinct().Count() == 3, + "three different verification failures reported the same thing: " + reasons[0]); + }, "the X509 reason is thrown away: SSL_get_verify_result is consulted only AFTER a handshake " + + "that succeeded, where it can only be X509_V_OK, and never on the failure path where it " + + "holds the code"); + } + + // --- driving the client ---------------------------------------------------------------------- + + /// The name every case that is not about naming uses, so nothing turns on it. + private const string OriginName = "origin.example.com"; + + private const string HandshakeMarker = "TLS handshake to"; + + /// + /// Fetch through the pooled client and return "<upstream status>|<detail>" - the + /// shape the other client suites use. A refused handshake arrives as 599 plus the cause. + /// + private static string Fetch(int originPort, string serverName, string caFile) + { + int proxy = StartProxy(originPort, new TlsClientOptions + { + ServerName = serverName, + AlpnProtocols = ["http/1.1"], + CaFile = caFile, + }); + + (int status, string body) = Client.Get(proxy, "/verify", timeoutMs: 20_000); + Assert.Equal(200, status); // the proxy handler always answers; the upstream call is the test + return body; + } + + /// + /// Serve to a client trusting , and hand back + /// what the client reported. Takes ownership of the leaf. + /// + private static string Refusal(string caFile, X509Certificate2 leaf) + { + using (leaf) + { + using Origin origin = Origin.Start(leaf); + return Fetch(origin.Port, OriginName, caFile); + } + } + + /// The message from the handshake onwards, with the pool's per-run prefix dropped. + private static string Reason(string body) + { + int at = body.IndexOf(HandshakeMarker, StringComparison.Ordinal); + Assert.True(at >= 0, $"the handshake failure should reach the caller, got: {body}"); + return body[at..]; + } + + private static int StartProxy(int originPort, TlsClientOptions tlsOptions) + { + TlsClientContext tls = TlsClientContext.Create(tlsOptions); + + var options = new HttpClientOptions + { + Host = "127.0.0.1", + Port = (ushort)originPort, + PoolSize = 1, + AcquireTimeoutMs = 5_000, + Tls = tls, + }; + + return TestServer.Start(ProxyHandler, onStart: reactor => HttpClientPool.Start(reactor, options)); + } + + private static async Task ProxyHandler(Reactor reactor, TcpConnection connection) + { + try + { + HttpClientPool upstream = reactor.GetService()!; + + while (true) + { + RecvSnapshot snapshot = await connection.ReadAsync(); + if (snapshot.IsClosed) + { + return; + } + string path = Wire.ReadPath(connection, snapshot); + + string detail; + int status; + try + { + using HttpClientResponse response = await upstream.GetAsync(path); + status = response.Status; + detail = Encoding.ASCII.GetString(response.Body.Span); + } + catch (Exception e) + { + status = 599; + detail = e.Message; + } + + Wire.Write(connection, 200, $"{status}|{detail}"); + await connection.FlushAsync(); + connection.ResetRead(); + } + } + finally + { + connection.DecRef(); + } + } + + // --- fixtures --------------------------------------------------------------------------------- + + /// + /// A PEM the client can be pointed at. Deleted when the test that made it finishes: these are + /// one-off shapes rather than fixtures, and leaving them behind would grow a directory forever + /// while inviting a later test to reuse one it did not mint. + /// + private sealed class PemFile : IDisposable + { + public required string Path { get; init; } + + public static PemFile Write(params X509Certificate2[] certificates) + { + // Flat in the temp directory rather than in one of our own: nothing here is a fixture + // to be found again, so there is no directory to leave behind either. + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"ioxide-httpclient-verify-{Environment.ProcessId}-{Guid.NewGuid():N}.pem"); + File.WriteAllText(path, string.Concat(certificates.Select(c => c.ExportCertificatePem() + "\n"))); + return new PemFile { Path = path }; + } + + public void Dispose() + { + try + { + File.Delete(Path); + } + catch (IOException) + { + // Best effort: a leftover PEM in the temp directory is not a test failure. + } + } + } + + /// A CA that can issue leaves and intermediates, and the PEM a client trusts it by. + private sealed class Authority : IDisposable + { + public required X509Certificate2 Certificate { get; init; } + public required PemFile Pem { private get; init; } + + public string PemPath => Pem.Path; + + public static Authority Mint() + { + using RSA key = RSA.Create(2048); + + // A distinct subject per authority: two roots with the same name in one process is the + // sort of coincidence that makes a chain build succeed for the wrong reason. + var request = new CertificateRequest( + $"CN=ioxide client-verify root {Guid.NewGuid().ToString("N")[..8]}", + key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add( + new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + + // Wide enough to hold an expired leaf: .NET refuses to issue one whose notBefore + // precedes its issuer's, so "expired" has to mean expired INSIDE this window. + using X509Certificate2 selfSigned = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddDays(365)); + X509Certificate2 ca = Usable(selfSigned); + + return new Authority { Certificate = ca, Pem = PemFile.Write(ca) }; + } + + public Authority Intermediate(string subject) + { + using RSA key = RSA.Create(2048); + var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, true, 0, true)); + request.CertificateExtensions.Add( + new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + + byte[] serial = new byte[8]; + RandomNumberGenerator.Fill(serial); + using X509Certificate2 signed = request.Create( + Certificate, DateTimeOffset.UtcNow.AddDays(-20), DateTimeOffset.UtcNow.AddDays(200), serial); + using X509Certificate2 withKey = signed.CopyWithPrivateKey(key); + X509Certificate2 intermediate = Usable(withKey); + + return new Authority { Certificate = intermediate, Pem = PemFile.Write(intermediate) }; + } + + public X509Certificate2 Leaf( + string subject, + string[]? dns = null, + IPAddress[]? ips = null, + DateTimeOffset? notBefore = null, + DateTimeOffset? notAfter = null) + { + using RSA key = RSA.Create(2048); + var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + var names = new SubjectAlternativeNameBuilder(); + foreach (string name in dns ?? []) + { + names.AddDnsName(name); + } + foreach (IPAddress ip in ips ?? []) + { + names.AddIpAddress(ip); + } + request.CertificateExtensions.Add(names.Build()); + request.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], false)); + + byte[] serial = new byte[8]; + RandomNumberGenerator.Fill(serial); + using X509Certificate2 signed = request.Create( + Certificate, + notBefore ?? DateTimeOffset.UtcNow.AddDays(-1), + notAfter ?? DateTimeOffset.UtcNow.AddDays(30), + serial); + using X509Certificate2 withKey = signed.CopyWithPrivateKey(key); + return Usable(withKey); + } + + public void Dispose() + { + Certificate.Dispose(); + Pem.Dispose(); + } + } + + private static X509Certificate2 SelfSigned(string subject, string dns) + { + using RSA key = RSA.Create(2048); + var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + var names = new SubjectAlternativeNameBuilder(); + names.AddDnsName(dns); + request.CertificateExtensions.Add(names.Build()); + request.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], false)); + + using X509Certificate2 signed = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30)); + return Usable(signed); + } + + /// + /// A PFX round trip, because SslStream on Linux will not present a certificate whose private + /// key is only attached in memory. + /// + private static X509Certificate2 Usable(X509Certificate2 withKey) + => X509CertificateLoader.LoadPkcs12(withKey.Export(X509ContentType.Pfx), null); + + // --- the origin -------------------------------------------------------------------------------- + + /// + /// An HTTPS origin holding one certificate of the caller's choosing. SslStream rather than one + /// of our own servers, so a served request means our client and an independent implementation + /// agreed - and so the suite needs no kTLS module to run. + /// + private sealed class Origin : IDisposable + { + private readonly TcpListener _listener; + private readonly X509Certificate2? _certificate; + private readonly CancellationTokenSource _stopping = new(); + + public int Port { get; } + + private Origin(TcpListener listener, X509Certificate2? certificate) + { + _listener = listener; + _certificate = certificate; + Port = ((IPEndPoint)listener.LocalEndpoint).Port; + } + + public static Origin Start(X509Certificate2 certificate) => StartCore(certificate); + + /// Accepts and closes without a handshake: a connection failure that is not a + /// certificate failure. + public static Origin StartClosingImmediately() => StartCore(certificate: null); + + private static Origin StartCore(X509Certificate2? certificate) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var origin = new Origin(listener, certificate); + _ = origin.AcceptAsync(); + return origin; + } + + private async Task AcceptAsync() + { + while (!_stopping.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(_stopping.Token); + } + catch + { + return; // stopped + } + + _ = ServeAsync(client); + } + } + + private async Task ServeAsync(TcpClient client) + { + using (client) + { + if (_certificate is null) + { + return; // close, having said nothing + } + + SslStream? tls = null; + try + { + tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false); + await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = _certificate, + ApplicationProtocols = [new SslApplicationProtocol("http/1.1")], + }); + + var request = new byte[8192]; + while (true) + { + int n = await tls.ReadAsync(request, _stopping.Token); + if (n == 0) + { + return; // peer closed + } + + const string body = "verified"; + byte[] response = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\n" + + $"content-length: {body.Length}\r\n" + + "content-type: text/plain\r\n\r\n" + + body); + await tls.WriteAsync(response, _stopping.Token); + } + } + catch + { + // A client that rejects this certificate is the POINT of most of these tests, + // so the handshake failing here is data rather than an error to report. + } + finally + { + tls?.Dispose(); + } + } + } + + public void Dispose() + { + _stopping.Cancel(); + _listener.Stop(); + _stopping.Dispose(); + } } } diff --git a/tests/Ioxide.Tests.Tls/AlpnNegotiationTests.cs b/tests/Ioxide.Tests.Tls/AlpnNegotiationTests.cs index 2d781c1d..91361496 100644 --- a/tests/Ioxide.Tests.Tls/AlpnNegotiationTests.cs +++ b/tests/Ioxide.Tests.Tls/AlpnNegotiationTests.cs @@ -1,3 +1,7 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Authentication; +using System.Text; using ioxide; using ioxide.tls; @@ -8,16 +12,335 @@ namespace Ioxide.Tests; /// malformed offer list does. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// Driven with SslStream offering real ALPN lists, and answered by a handler that echoes +/// in the body - so each case pins BOTH views of the +/// negotiation: what the client was told, and what a handler would branch on to pick an h2 loop +/// over an h1 one. The select loop is additionally pinned by reflection for the malformed offers +/// OpenSSL never forwards - that test says why end-to-end cannot deliver them. /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// One live defect rides as Pending: BuildAlpnWire casts each UTF-16 unit to a byte, so a +/// non-ASCII protocol name goes on the wire as a DIFFERENT protocol made of its chars' low bytes. /// internal static class AlpnNegotiationTests { + // OpenSSL's tlsext callback results, restated because the binding class is internal to the + // module. The values are ABI, not convention - they cannot drift. + private const int TlsExtErrOk = 0; // SSL_TLSEXT_ERR_OK + private const int TlsExtErrNoAck = 3; // SSL_TLSEXT_ERR_NOACK + public static void Register(Runner runner) { + runner.Test("alpn: the server's preference order decides, not the client's", () => + { + // The one configuration in which server preference and client preference are + // distinguishable: the SAME two-protocol offer, to two servers that differ only in + // list order. The pair has to answer differently, which no order-independent + // selection - client preference, hardcoded h2, first-offered - can do. + string[] offer = ["http/1.1", "h2"]; + + int h2First = StartEcho(["h2", "http/1.1"]); + (_, string a, _, string bodyA) = Client.GetTlsSni(h2First, "/", null, alpn: offer); + Assert.Equal("h2", a); + Assert.True(bodyA.Contains("alpn=h2"), + $"the handler must see the same choice the client was told; body: {bodyA}"); + + int h1First = StartEcho(["http/1.1", "h2"]); + (_, string b, _, string bodyB) = Client.GetTlsSni(h1First, "/", null, alpn: offer); + Assert.Equal("http/1.1", b); + Assert.True(bodyB.Contains("alpn=http/1.1"), + $"the handler must see the same choice the client was told; body: {bodyB}"); + }); + + runner.Test("alpn: a protocol the client did not offer is skipped, not imposed", () => + { + // The walk has to CONTINUE past our favourite when the client lacks it, rather than + // selecting it anyway - a client told a protocol it never offered aborts. + int port = StartEcho(["h2", "http/1.1"]); + + (_, string alpn, int status, string body) = Client.GetTlsSni(port, "/", null, alpn: ["http/1.1"]); + + Assert.Equal("http/1.1", alpn); + Assert.Equal(200, status); + Assert.True(body.Contains("alpn=http/1.1"), $"the handler must see http/1.1; body: {body}"); + }); + + runner.Test("alpn: no shared protocol continues without alpn rather than alerting", () => + { + // RFC 7301 3.2 says a server supporting none of the client's protocols SHALL alert + // no_application_protocol. The callback deliberately deviates - NOACK continues the + // handshake without the extension, on the theory that the client may still speak + // HTTP/1.1. Pinned so a posture change to the RFC's alert is a conscious edit here, + // not a silent one. + int port = StartEcho(["h2"]); + + (_, string alpn, int status, string body) = Client.GetTlsSni(port, "/", null, alpn: ["spdy/3"]); + + Assert.Equal("", alpn); + Assert.Equal(200, status); + Assert.True(body.Contains("alpn=none"), + $"with nothing in common the handler must see no protocol, not a guess; body: {body}"); + }); + + runner.Test("alpn: a client offering no alpn at all is served, and the server records none", () => + { + // No extension in the ClientHello means the select callback never runs at all - the + // curl-without-alpn case. The handshake must complete, and the handler's view must be + // null rather than a stale or defaulted protocol. + int port = StartEcho(["h2", "http/1.1"]); + + (_, string alpn, int status, string body) = Client.GetTlsSni(port, "/", null, alpn: null); + + Assert.Equal("", alpn); + Assert.Equal(200, status); + Assert.True(body.Contains("alpn=none"), + $"a client that offered nothing must not be recorded as having negotiated; body: {body}"); + }); + + runner.Test("alpn: the handler's NegotiatedAlpn matches what the client was told", () => + { + // TlsSession.NegotiatedAlpn is what a handler serving h2 and h1 on one port branches + // on. Nothing else in the suite reads it, so a CaptureAlpn that captured nothing - or + // the wrong thing - was invisible until here. + int port = StartEcho(["h2", "http/1.1"]); + + (_, string alpn, _, string body) = Client.GetTlsSni(port, "/", null, alpn: ["h2"]); + + Assert.Equal("h2", alpn); + Assert.True(body.Contains("alpn=h2"), + $"the session must record the protocol the client was told; body: {body}"); + }); + + runner.Test("alpn: a 255-byte protocol name negotiates at the length limit", () => + { + // 255 is the largest length the wire's one-byte prefix can carry, and the largest + // BuildAlpnWire accepts - the boundary where an off-by-one in either shows up. + string big = new string('a', 255); + int port = StartEcho([big]); + + (_, string alpn, int status, string body) = Client.GetTlsSni(port, "/", null, alpn: [big]); + + Assert.Equal(big, alpn); + Assert.Equal(200, status); + Assert.True(body.Contains($"alpn={big}"), "the handler must see the full 255-byte name"); + }); + + runner.Test("alpn: an unusable protocol list is refused at start", () => + { + // The wire cannot express these - an empty list negotiates nothing forever, a + // zero-length name is banned by RFC 7301, and 256 does not fit a one-byte length - + // so each must be refused loudly at start, not built and left to fail per handshake. + // The control for all three is every other test in this file starting successfully. + Assert.True(StartFails([], "At least one ALPN protocol"), + "an empty protocol list must be refused at start"); + Assert.True(StartFails([""], "must be 1..255"), + "a zero-length protocol name must be refused at start"); + Assert.True(StartFails([new string('a', 256)], "must be 1..255"), + "a 256-byte protocol name must be refused at start"); + }); + + RegisterSelectLoopBounds(runner); + + runner.Pending("alpn: a non-ascii protocol name must not negotiate as a different protocol", () => + { + // BuildAlpnWire writes (byte)protocol[i] - the LOW BYTE of each UTF-16 unit. U+0168 + // has low byte 0x68, which is 'h', so a server configured to speak only "\u0168" + "2" + // puts the exact bytes "h2" on the wire and ACKs any client offering h2. Two distinct + // protocol identifiers compare equal, the client is told h2, and CaptureAlpn records + // h2 - a protocol the operator never listed. (The same cast is why the SAME non-ASCII + // string configured on both sides fails to match: SslApplicationProtocol sends UTF-8.) + string exotic = "\u0168" + "2"; // two chars whose low bytes are 'h','2' + + (string cert, string key) = TestCert.Ensure(); + int port; + try + { + port = TestServer.Start(EchoAlpn, r => TlsService.Start(r, new TlsOptions + { + CertificatePath = cert, + KeyPath = key, + Alpn = [exotic], + })); + } + catch (Exception e) when (e.Message.Contains("ALPN") || e.Message.Contains('\u0168')) + { + // Refusing the configuration loudly is the other acceptable resolution. Narrow on + // purpose: an unrelated failure to start must stay a failure, not a quiet pass. + return; + } + + // Control first: the server is alive and serves a client that negotiates nothing, so + // the probe below cannot "pass" against a dead port. + (_, _, int status, _) = Client.GetTlsSni(port, "/", null); + Assert.Equal(200, status); + + string negotiated; + try + { + (_, negotiated, _, _) = Client.GetTlsSni(port, "/", null, alpn: ["h2"]); + } + catch (AuthenticationException) + { + negotiated = ""; // declining to speak h2 satisfies the claim too + } + + Assert.True(negotiated != "h2", + "a server configured to speak only 'U+0168 2' ACKed h2 - the char-to-byte cast in " + + "BuildAlpnWire makes two distinct protocol identifiers compare equal"); + }, "BuildAlpnWire casts UTF-16 units to bytes, so the configured \"\\u01682\" goes on the wire " + + "as the bytes 'h2' and the server negotiates - and CaptureAlpn records - a protocol " + + "nobody configured"); + } + + /// + /// The select loop's own bounds, driven by reflection with offer lists a real handshake can + /// never deliver: OpenSSL validates the ClientHello's ALPN list while parsing it (empty or + /// truncated entries are a decode_error alert) and hands the callback only the validated + /// copy, so the loop's malformed-input handling is defence in depth that end-to-end tests + /// structurally cannot reach. Unreachable is not unpinnable: if the bound is "simplified" + /// away, this is the only test that notices. + /// + private static void RegisterSelectLoopBounds(Runner runner) + { + runner.Test("alpn: a truncated or empty offer entry is declined, never matched or overread", () => + { + MethodInfo? core = typeof(TlsService).GetMethod( + "AlpnSelectCore", BindingFlags.NonPublic | BindingFlags.Static); + Assert.True(core is not null, + "AlpnSelectCore not found - the select loop moved; move this pin with it"); + + byte[] wire = [2, (byte)'h', (byte)'2']; // the server speaks h2 - built by hand, as BuildAlpnWire would + GCHandle wireHandle = GCHandle.Alloc(wire); + nint outSlot = Marshal.AllocHGlobal(IntPtr.Size); + nint outLen = Marshal.AllocHGlobal(1); + nint offer = Marshal.AllocHGlobal(64); + + try + { + int Run(byte[] bytes) + { + Marshal.WriteIntPtr(outSlot, (nint)(-1)); // sentinels: a decline must leave both untouched + Marshal.WriteByte(outLen, 0xEE); + Marshal.Copy(bytes, 0, offer, bytes.Length); + return (int)core!.Invoke(null, [outSlot, outLen, offer, (uint)bytes.Length, GCHandle.ToIntPtr(wireHandle)])!; + } + + // Control: a well-formed offer with h2 second matches, and *out points at the + // NAME, not its length byte - the pointer arithmetic under test. + int ok = Run([3, (byte)'f', (byte)'o', (byte)'o', 2, (byte)'h', (byte)'2']); + Assert.Equal(TlsExtErrOk, ok); + Assert.True(Marshal.ReadIntPtr(outSlot) == offer + 5, + "the selection must point at the protocol name inside the client's buffer"); + Assert.Equal((byte)2, Marshal.ReadByte(outLen)); + + // An entry claiming more bytes than the list holds: declined, nothing written. + int truncated = Run([5, (byte)'h', (byte)'2']); + Assert.Equal(TlsExtErrNoAck, truncated); + Assert.True(Marshal.ReadIntPtr(outSlot) == (nint)(-1), + "a declined offer must not have written a selection"); + Assert.Equal((byte)0xEE, Marshal.ReadByte(outLen)); + + // Truncation AFTER a valid non-matching entry: the scan stops at the bound. + Assert.Equal(TlsExtErrNoAck, Run([3, (byte)'f', (byte)'o', (byte)'o', 9, (byte)'h'])); + + // Zero-length entries only: skipped without wedging (returning at all is the + // no-infinite-loop assertion; the runner's watchdog backs it), and nothing selected. + Assert.Equal(TlsExtErrNoAck, Run([0, 0, 0])); + + // A zero-length entry AHEAD of a real one does not derail the scan. + int skipped = Run([0, 2, (byte)'h', (byte)'2']); + Assert.Equal(TlsExtErrOk, skipped); + Assert.True(Marshal.ReadIntPtr(outSlot) == offer + 2, + "the match after an empty entry must still point at its name"); + + // An empty list, the degenerate bound. + Assert.Equal(TlsExtErrNoAck, Run([])); + } + finally + { + wireHandle.Free(); + Marshal.FreeHGlobal(outSlot); + Marshal.FreeHGlobal(outLen); + Marshal.FreeHGlobal(offer); + } + }); + } + + /// Echo server: the body reports the ALPN the SESSION captured, so every test pins + /// the server's view of the negotiation and not only the client's. + private static int StartEcho(string[] alpn) + { + (string cert, string key) = TestCert.Ensure(); + + return TestServer.Start(EchoAlpn, r => TlsService.Start(r, new TlsOptions + { + CertificatePath = cert, + KeyPath = key, + Alpn = alpn, + })); + } + + /// Whether starting with this ALPN list is refused for the stated reason. + private static bool StartFails(string[] alpn, string because) + { + (string cert, string key) = TestCert.Ensure(); + + try + { + TestServer.Start(EchoAlpn, r => TlsService.Start(r, new TlsOptions + { + CertificatePath = cert, + KeyPath = key, + Alpn = alpn, + })); + return false; + } + catch (Exception e) + { + return e.Message.Contains(because); + } + } + + /// + /// The send-first TLS loop (see Handlers.TlsSendFirst for why answering precedes the first + /// park), with one difference: the response body is "alpn=<negotiated>", read off + /// the way a real handler would branch on it. + /// + private static async Task EchoAlpn(Reactor reactor, TcpConnection connection) + { + TlsSession? session = null; + + try + { + session = await reactor.GetService()!.AcceptAsync(connection); + + string view = session.NegotiatedAlpn ?? "none"; + byte[] response = Encoding.ASCII.GetBytes( + $"HTTP/1.1 200 OK\r\ncontent-length: {5 + view.Length}\r\n\r\nalpn={view}"); + + if (!session.DrainPlaintext().IsEmpty) + { + session.Write(connection, response); + await connection.FlushAsync(); + } + + while (true) + { + RecvSnapshot snapshot = await connection.ReadAsync(); + + if (snapshot.IsClosed) + { + return; + } + + session.Write(connection, response); + await connection.FlushAsync(); + connection.ResetRead(); + } + } + finally + { + session?.Dispose(); + connection.DecRef(); + } } } diff --git a/tests/Ioxide.Tests.Tls/AnchorSourceTests.cs b/tests/Ioxide.Tests.Tls/AnchorSourceTests.cs index c290f408..198af64e 100644 --- a/tests/Ioxide.Tests.Tls/AnchorSourceTests.cs +++ b/tests/Ioxide.Tests.Tls/AnchorSourceTests.cs @@ -1,3 +1,10 @@ +using System.Formats.Asn1; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; using ioxide; using ioxide.tls; @@ -8,16 +15,558 @@ namespace Ioxide.Tests; /// not be: ordering, encodings, auxiliary trust settings, bundle size. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// The two sources reach OpenSSL by different doors, which is why the claim is worth testing at +/// all: the path goes through SSL_CTX_load_verify_locations, which parses the bundle with +/// PEM_X509_INFO_read_bio, while the text goes through a PEM_read_bio_X509 loop. Those two readers +/// do NOT accept the same set of blocks, and the difference is silent - a block one of them does +/// not recognise is skipped, not reported. /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// The clients are driven with a selection callback that presents the certificate it was handed +/// whatever the server hinted. That is deliberate: the acceptable-CA hint is built by +/// SSL_load_client_CA_file / SSL_CTX_add_client_CA, which is a SEPARATE question from what the +/// store trusts, and neither source hints an anchor it read from a TRUSTED CERTIFICATE block. A +/// client that filtered on the hint would send nothing, and "sent nothing" is indistinguishable at +/// the server from "was not trusted" - so the callback keeps these tests measuring the trust +/// decision rather than a client-side heuristic. /// internal static class AnchorSourceTests { public static void Register(Runner runner) { + // Control, and the fixture check for the Pending below. Both anchors of the one bundle + // admit their client when the bundle is read from a path, so anything the PEM-text route + // does differently is the source and not the certificates. + runner.Test("anchors: a TRUSTED CERTIFICATE block in a bundle read from a path is trusted", () => + { + Anchors anchors = Fixture.Value; + + int port = TestServer.Start(Handlers.TlsCommonName, r => TlsService.Start(r, new TlsOptions + { + CertificatePath = anchors.ServerCert, + KeyPath = anchors.ServerKey, + ClientCaPath = anchors.BundlePath, + RequireClientCertificate = true, + })); + + (Client.TlsOutcome trusted, string alice) = Present(port, anchors.AliceCert, anchors.AliceKey); + Assert.Equal(Client.TlsOutcome.Served, trusted); + Assert.Equal("alice", alice); + + (Client.TlsOutcome plain, string bob) = Present(port, anchors.BobCert, anchors.BobKey); + Assert.Equal(Client.TlsOutcome.Served, plain); + Assert.Equal("bob", bob); + }); + + runner.Pending("anchors: a TRUSTED CERTIFICATE block is trusted from PEM text as it is from a path", () => + { + Anchors anchors = Fixture.Value; + + int port = TestServer.Start(Handlers.TlsCommonName, r => TlsService.Start(r, new TlsOptions + { + CertificatePath = anchors.ServerCert, + KeyPath = anchors.ServerKey, + ClientCaPem = anchors.BundlePem, // the same bytes, handed over as a value + RequireClientCertificate = true, + })); + + // Not vacuous: the PLAIN block of the same bundle is loaded, so this server is up, is + // asking for a certificate and is verifying what it gets. The two clients differ in one + // thing - which of the two anchors in the one bundle issued them. + (Client.TlsOutcome plain, string bob) = Present(port, anchors.BobCert, anchors.BobKey); + Assert.Equal(Client.TlsOutcome.Served, plain); + Assert.Equal("bob", bob); + + (Client.TlsOutcome trusted, string alice) = Present(port, anchors.AliceCert, anchors.AliceKey); + Assert.Equal(Client.TlsOutcome.Served, trusted); + Assert.Equal("alice", alice); + }, + "the PEM-text route reads the bundle with PEM_read_bio_X509, which SKIPS a block labelled " + + "TRUSTED CERTIFICATE and reports nothing; the file route reads the same block through " + + "load_verify_locations and trusts it, auxiliary trust settings and all. The bundle loads, " + + "the server starts, and one anchor of it is silently gone - the shape of a trusted-CA " + + "bundle from an OpenSSL trust store (ca-bundle.trust.crt) or 'openssl x509 -trustout'"); + + RegisterIssuerHint(runner); + RegisterEncoding(runner); + } + + /// + /// What the PEM-text route does to bytes on the way in. It converts the string through + /// Encoding.ASCII, so every non-ASCII character becomes a question mark before OpenSSL is + /// handed anything - and OpenSSL knows what to do with one of those bytes. + /// + private static void RegisterEncoding(Runner runner) + { + // Control. A byte-order mark in front of a PEM bundle is what Windows tooling writes, which + // is why OpenSSL strips one from the first line rather than choking on it. + runner.Test("anchors: a bundle with a byte-order mark read from a path is trusted", () => + { + Anchors anchors = Fixture.Value; + + int port = TestServer.Start(Handlers.TlsCommonName, r => TlsService.Start(r, new TlsOptions + { + CertificatePath = anchors.ServerCert, + KeyPath = anchors.ServerKey, + ClientCaPath = anchors.BomPath, + RequireClientCertificate = true, + })); + + (Client.TlsOutcome outcome, string alice) = Present(port, anchors.AliceCert, anchors.AliceKey); + Assert.Equal(Client.TlsOutcome.Served, outcome); + Assert.Equal("alice", alice); + }); + + runner.Pending("anchors: a bundle with a byte-order mark is trusted as PEM text as it is from a path", () => + { + Anchors anchors = Fixture.Value; + + int port = TestServer.Start(Handlers.TlsCommonName, r => TlsService.Start(r, new TlsOptions + { + CertificatePath = anchors.ServerCert, + KeyPath = anchors.ServerKey, + ClientCaPem = anchors.BomPem, // the same characters the file holds + RequireClientCertificate = true, + })); + + (Client.TlsOutcome outcome, string alice) = Present(port, anchors.AliceCert, anchors.AliceKey); + Assert.Equal(Client.TlsOutcome.Served, outcome); + Assert.Equal("alice", alice); + }, + "AddTrustAnchorsPem converts with Encoding.ASCII, so U+FEFF reaches OpenSSL as '?' - and " + + "OpenSSL strips a UTF-8 byte-order mark from the first line of a bundle but has no reason " + + "to strip a question mark, so no block is ever recognised and the whole bundle reads as " + + "empty. The file route hands the same three bytes over untouched and they are stripped. " + + "The route documented for hosts that carry certificates as DATA is the one that cannot " + + "take what a secrets store filled from Windows tooling holds"); + } + + /// + /// The other half of the documented equivalence: the acceptable-CA hint, which the server + /// lists in its CertificateRequest and which a client holding several certificates chooses by. + /// Read off the wire here - see - because it is a claim worth + /// checking rather than assuming. + /// + private static void RegisterIssuerHint(Runner runner) + { + // Control, and the proof that the hint reaches a client here at all - without it the + // Pending below would pass for an empty list as readily as for a correct one. + runner.Test("anchors: an anchor written twice in a bundle read from a path is hinted once", () => + { + Anchors anchors = Fixture.Value; + + int port = TestServer.Start(Handlers.TlsCommonName, r => TlsService.Start(r, new TlsOptions + { + CertificatePath = anchors.ServerCert, + KeyPath = anchors.ServerKey, + ClientCaPath = anchors.RepeatedPath, + RequireClientCertificate = true, + })); + + List hint = HintedAuthorities(port, anchors.AliceCert, anchors.AliceKey); + + Assert.Equal(1, hint.Count); + Assert.True(hint[0].Contains("ioxide test CA"), $"hinted something else: {hint[0]}"); + }); + + runner.Pending("anchors: an anchor written twice is hinted once from PEM text as it is from a path", () => + { + Anchors anchors = Fixture.Value; + + int port = TestServer.Start(Handlers.TlsCommonName, r => TlsService.Start(r, new TlsOptions + { + CertificatePath = anchors.ServerCert, + KeyPath = anchors.ServerKey, + ClientCaPem = anchors.RepeatedPem, // the same bytes again + RequireClientCertificate = true, + })); + + List hint = HintedAuthorities(port, anchors.AliceCert, anchors.AliceKey); + + // Not vacuous: HintedAuthorities requires the handshake to have completed and the + // request to have been answered, so an empty or unparsed hint fails as loudly as a + // wrong one - and it says "alice", so this anchor is genuinely trusted here. + Assert.Equal(1, hint.Count); + }, + "the file route builds the hint with SSL_load_client_CA_file, which drops a duplicate name; " + + "the PEM-text route pushes one with SSL_CTX_add_client_CA per certificate read and never " + + "compares, so the same bundle sends the same issuer twice - and a bundle that repeats a " + + "dozen anchors sends a CertificateRequest of twice the size to every client"); + } + + /// + /// A bundle holding two anchors in the two PEM spellings a trust store uses, the certificates + /// they issued, and the server's own pair. + /// + private sealed record Anchors( + string ServerCert, string ServerKey, + string BundlePath, string BundlePem, + string RepeatedPath, string RepeatedPem, + string BomPath, string BomPem, + string AliceCert, string AliceKey, + string BobCert, string BobKey); + + private static readonly Lazy Fixture = new(Mint); + + /// + /// The bundle: the mutual-TLS CA as a TRUSTED CERTIFICATE carrying an auxiliary trust setting + /// for TLS client authentication, then a second CA as an ordinary CERTIFICATE. One file, two + /// anchors, two clients - one issued by each. + /// + /// + /// The second anchor is minted here rather than taken from TestCert because the point is a + /// bundle whose blocks are spelled differently: with only the trusted block the PEM-text route + /// would refuse to start ("contained no certificates"), which is a loud failure and a different + /// finding. With a plain block beside it the route starts perfectly happily, having quietly + /// dropped half of what it was given. + /// + private static Anchors Mint() + { + (string ca, string serverCert, string serverKey, string aliceCert, string aliceKey, _, _) + = TestCert.EnsureMutualTls(); + + string dir = Directory.CreateTempSubdirectory("ioxide-anchor-source-").FullName; + + // One window for the pair: .NET refuses to issue a leaf whose notBefore precedes its + // issuer's, and taking UtcNow twice can straddle a second boundary. + DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddDays(-1); + + using RSA secondCaKey = RSA.Create(2048); + var secondCaRequest = new CertificateRequest( + "CN=ioxide second anchor CA", secondCaKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + secondCaRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + using X509Certificate2 secondCa = secondCaRequest.CreateSelfSigned(notBefore, notBefore.AddYears(2)); + + using RSA bobKey = RSA.Create(2048); + var bobRequest = new CertificateRequest( + "CN=bob", bobKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + bobRequest.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.2")], false)); + + byte[] serial = new byte[8]; + RandomNumberGenerator.Fill(serial); + using X509Certificate2 bob = bobRequest.Create(secondCa, notBefore, notBefore.AddYears(1), serial); + + string bobCert = Path.Combine(dir, "bob.crt"); + string bobKeyPath = Path.Combine(dir, "bob.key"); + File.WriteAllText(bobCert, bob.ExportCertificatePem()); + File.WriteAllText(bobKeyPath, bobKey.ExportPkcs8PrivateKeyPem()); + + string bundle = TrustedCertificateBlock(ca) + secondCa.ExportCertificatePem() + "\n"; + string bundlePath = Path.Combine(dir, "anchors.pem"); + File.WriteAllText(bundlePath, bundle); + + // The other bundle: ONE anchor, written twice. What two overlapping trust stores + // concatenated together look like, and the cheapest way to ask whether the acceptable-CA + // hint is built the same way from both sources. + string once = File.ReadAllText(ca).TrimEnd() + "\n"; + string repeated = once + once; + string repeatedPath = Path.Combine(dir, "repeated.pem"); + File.WriteAllText(repeatedPath, repeated); + + // And the third: one anchor behind a byte-order mark. The file and the string hold the same + // characters - writing U+FEFF as UTF-8 IS the three-byte mark - so the two sources are + // being given the same bundle in the two shapes the API offers. + string bom = "\uFEFF" + once; + string bomPath = Path.Combine(dir, "bom.pem"); + File.WriteAllText(bomPath, bom, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + return new Anchors( + serverCert, serverKey, bundlePath, bundle, repeatedPath, repeated, bomPath, bom, + aliceCert, aliceKey, bobCert, bobKeyPath); + } + + /// + /// One certificate as a TRUSTED CERTIFICATE block: its own DER with an X509_CERT_AUX appended, + /// which is all the format is. The aux here says the anchor is trusted for TLS client + /// authentication - the setting a server doing mutual TLS is the one to care about. + /// + /// + /// X509_CERT_AUX ::= SEQUENCE { trust SEQUENCE OF OBJECT IDENTIFIER OPTIONAL, ... }, so the + /// fourteen bytes appended are 30 0C 30 0A 06 08 2B 06 01 05 05 07 03 02 - byte for byte what + /// openssl x509 -addtrust clientAuth -trustout writes. + /// + private static string TrustedCertificateBlock(string certPath) + { + using X509Certificate2 anchor = X509CertificateLoader.LoadCertificateFromFile(certPath); + + var aux = new AsnWriter(AsnEncodingRules.DER); + using (aux.PushSequence()) + { + using (aux.PushSequence()) + { + aux.WriteObjectIdentifier("1.3.6.1.5.5.7.3.2"); + } + } + + return PemBlock("TRUSTED CERTIFICATE", [.. anchor.RawData, .. aux.Encode()]); + } + + private static string PemBlock(string label, byte[] der) + { + var pem = new StringBuilder(); + pem.Append("-----BEGIN ").Append(label).Append("-----\n"); + + string base64 = Convert.ToBase64String(der); + for (int at = 0; at < base64.Length; at += 64) + { + pem.Append(base64, at, Math.Min(64, base64.Length - at)).Append('\n'); + } + + return pem.Append("-----END ").Append(label).Append("-----\n").ToString(); + } + + /// + /// Presents one certificate and reports how the attempt ended and what identity the handler + /// saw. The outcome is classified rather than caught: "the server refused this client" must not + /// also be satisfied by the server hanging, by the port belonging to something else, or by the + /// fixture failing to load. + /// + /// + /// The selection callback is the reason this is not . + /// Neither anchor source hints an anchor it read from a TRUSTED CERTIFICATE block - + /// SSL_load_client_CA_file uses the same reader as the PEM-text route - and a client that + /// picked its certificate by that hint would send nothing at all, which the server cannot tell + /// apart from a certificate it refused. Presenting it regardless leaves exactly one thing + /// deciding the outcome: whether the server trusts the anchor that issued it. + /// + private static (Client.TlsOutcome Outcome, string Identity) Present( + int port, string certPath, string keyPath) + { + using X509Certificate2 pem = X509Certificate2.CreateFromPemFile(certPath, keyPath); + + // SslStream on Linux needs the key associated through a PFX round-trip, exactly as the + // harness client does it. + using X509Certificate2 usable = X509CertificateLoader.LoadPkcs12(pem.Export(X509ContentType.Pfx), null); + + try + { + using var socket = new TcpClient(); + socket.Connect("127.0.0.1", port); + socket.ReceiveTimeout = 6000; + + using var ssl = new SslStream( + socket.GetStream(), leaveInnerStreamOpen: false, + (_, _, _, _) => true, + (_, _, _, _, _) => usable); + + ssl.AuthenticateAsClient(new SslClientAuthenticationOptions + { + TargetHost = "localhost", + EnabledSslProtocols = SslProtocols.Tls13, + ClientCertificates = new X509CertificateCollection { usable }, + }); + + // The request is not incidental. Under TLS 1.3 the client's Certificate is sent after + // the server's Finished, so a server rejecting it has nothing left to interrupt: the + // handshake returns happily and the alert only arrives on the next read. + ssl.Write(Encoding.ASCII.GetBytes("GET /who HTTP/1.1\r\nHost: test\r\n\r\n")); + ssl.Flush(); + + (int status, string body) = Client.ReadResponse(ssl); + return status > 0 + ? (Client.TlsOutcome.Served, body) + : (Client.TlsOutcome.Dropped, body); + } + catch (AuthenticationException) + { + return (Client.TlsOutcome.Refused, string.Empty); + } + catch (Exception e) when (e is IOException && Inner(e) is not null) + { + return (Client.TlsOutcome.Refused, string.Empty); + } + catch (Exception e) when (Inner(e) is { SocketErrorCode: SocketError.TimedOut }) + { + return (Client.TlsOutcome.TimedOut, string.Empty); + } + catch (IOException) + { + return (Client.TlsOutcome.Dropped, string.Empty); + } + catch (Exception e) when (e.Message.Contains("closed before headers", StringComparison.Ordinal)) + { + return (Client.TlsOutcome.Dropped, string.Empty); + } + + static T? Inner(Exception e) where T : Exception + { + for (Exception? at = e; at is not null; at = at.InnerException) + { + if (at is T match) + { + return match; + } + } + + return null; + } + } + + /// + /// The certificate authorities the server listed in its CertificateRequest, read off the wire. + /// + /// + /// Off the wire because there is nowhere else to read them here: SslStream on Linux hands its + /// selection callback an EMPTY acceptable-issuers array, whatever the server sent. So the + /// handshake is pinned to TLS 1.2, where CertificateRequest is still plaintext, and the bytes + /// the client read are teed off on the way past. The request afterwards is what makes an empty + /// or misparsed result impossible to confuse with a hint that was really empty: the identity + /// has to come back, so the handshake has to have completed and the certificate has to have + /// been accepted. + /// + private static List HintedAuthorities(int port, string certPath, string keyPath) + { + using X509Certificate2 pem = X509Certificate2.CreateFromPemFile(certPath, keyPath); + using X509Certificate2 usable = X509CertificateLoader.LoadPkcs12(pem.Export(X509ContentType.Pfx), null); + + using var socket = new TcpClient(); + socket.Connect("127.0.0.1", port); + socket.ReceiveTimeout = 6000; + + var wire = new Tee(socket.GetStream()); + using var ssl = new SslStream( + wire, leaveInnerStreamOpen: false, + (_, _, _, _) => true, + (_, _, _, _, _) => usable); + + ssl.AuthenticateAsClient(new SslClientAuthenticationOptions + { + TargetHost = "localhost", + EnabledSslProtocols = SslProtocols.Tls12, + ClientCertificates = new X509CertificateCollection { usable }, + }); + + ssl.Write(Encoding.ASCII.GetBytes("GET /who HTTP/1.1\r\nHost: test\r\n\r\n")); + ssl.Flush(); + + (int status, string identity) = Client.ReadResponse(ssl); + Assert.Equal(200, status); + Assert.Equal("alice", identity); + + return CertificateAuthorities(wire.Seen.ToArray()); + } + + /// + /// Walks a recorded server flight - records, then handshake messages - and returns the subject + /// names carried by the CertificateRequest (type 13). Empty if there was none. + /// + private static List CertificateAuthorities(byte[] fromServer) + { + var handshake = new List(); + + for (int at = 0; at + 5 <= fromServer.Length;) + { + int type = fromServer[at]; + int length = (fromServer[at + 3] << 8) | fromServer[at + 4]; + if (at + 5 + length > fromServer.Length || type == 20) + { + break; // ChangeCipherSpec: everything past it is encrypted + } + + if (type == 22) + { + handshake.AddRange(new ArraySegment(fromServer, at + 5, length)); + } + + at += 5 + length; + } + + byte[] messages = [.. handshake]; + + for (int at = 0; at + 4 <= messages.Length;) + { + int type = messages[at]; + int length = (messages[at + 1] << 16) | (messages[at + 2] << 8) | messages[at + 3]; + int body = at + 4; + + if (body + length > messages.Length) + { + break; + } + + if (type == 13) + { + return Authorities(messages, body, body + length); + } + + at = body + length; + } + + return []; + } + + // certificate_types, then supported_signature_algorithms, then the DER-encoded issuer names - + // RFC 5246 7.4.4, which is the layout only TLS 1.2 has. + private static List Authorities(byte[] message, int at, int end) + { + at += 1 + message[at]; + at += 2 + ((message[at] << 8) | message[at + 1]); + + int listEnd = Math.Min(at + 2 + ((message[at] << 8) | message[at + 1]), end); + at += 2; + + var names = new List(); + while (at + 2 <= listEnd) + { + int length = (message[at] << 8) | message[at + 1]; + at += 2; + + if (at + length > listEnd) + { + break; + } + + names.Add(new X500DistinguishedName(message[at..(at + length)]).Name); + at += length; + } + + return names; + } + + /// Passes bytes through and keeps a copy of everything READ, so a plaintext handshake + /// can be inspected after SslStream has performed it. + private sealed class Tee(Stream inner) : Stream + { + public MemoryStream Seen { get; } = new(); + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + int read = inner.Read(buffer, offset, count); + if (read > 0) + { + Seen.Write(buffer, offset, read); + } + + return read; + } + + public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); + + public override void Flush() => inner.Flush(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + } + + base.Dispose(disposing); + } } } diff --git a/tests/Ioxide.Tests.Tls/ContextBuildTests.cs b/tests/Ioxide.Tests.Tls/ContextBuildTests.cs index bb80a44b..b5d7d2af 100644 --- a/tests/Ioxide.Tests.Tls/ContextBuildTests.cs +++ b/tests/Ioxide.Tests.Tls/ContextBuildTests.cs @@ -1,3 +1,6 @@ +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; using ioxide; using ioxide.tls; @@ -8,16 +11,257 @@ namespace Ioxide.Tests; /// fails partway. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// The shape every entry here has in common is the one this module treats as the worst kind: a +/// configuration ACCEPTED when the service starts, which then does not do what it says. An empty +/// ciphersuite list, a typo'd suite name and a certificate whose key is of another algorithm were +/// each that, and each is now refused at startup with the offender named. These are the ones still +/// accepted, and each is written the way the fix should leave it - refused for a stated reason, or +/// actually working - so it turns green whichever of the two the fix chooses. /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// Driven with SslStream as the client, because what is worth asserting is that a real peer is or +/// is not served, not that ioxide called a setter. /// internal static class ContextBuildTests { + /// + /// A TLS 1.2 suite under the IANA name for it. OpenSSL KNOWS this name, which is the whole + /// problem: SSL_CTX_set_ciphersuites looks names up by their IANA std name over the + /// WHOLE cipher table, not over the TLS 1.3 suites, so it accepts this one and files it in a + /// list only a 1.3 handshake ever reads - where a 1.2 cipher is then filtered back out. + /// + /// + /// Reachable by an ordinary route rather than an exotic one. RFC 8446 names the 1.3 suites in + /// exactly this style (TLS_AES_128_GCM_SHA256), and every compliance list, IANA table, + /// Wireshark capture and Java SSLParameters config spells the 1.2 ones the same way, so + /// "the suites we are allowed to offer" copied from any of them looks like a valid list. + /// OpenSSL's own short names (ECDHE-RSA-AES128-GCM-SHA256) are the ones that do not. + /// + private const string Tls12SuiteName = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"; + public static void Register(Runner runner) { + runner.Pending("ctx: a CipherSuites name that is not a TLS 1.3 suite is refused, not accepted into a port that serves nobody", () => + { + (string cert, string key) = TestCert.Ensure(); + + // Nothing else is set: no floor, no kTLS. The default posture serves TLS 1.2 and 1.3, + // and this is the whole of the operator's stated policy. + Built alone = Build(new TlsOptions + { + CertificatePath = cert, + KeyPath = key, + CipherSuites = Tls12SuiteName, + }); + + if (alone.Refusal is { } refusal) + { + Assert.True(refusal.Contains(Tls12SuiteName), + $"startup was refused, but not for the suite named: {refusal}"); + } + else + { + // It started. Then it has to be able to serve SOMEONE. The per-name validation + // loop passed every name here, the full list applied, and the context came out of + // it with no usable suite at either version - which is the end state an EMPTY + // CipherSuites is already refused for, reached through a name OpenSSL accepts. + Assert.True(Handshakes(alone.Port, SslProtocols.Tls12 | SslProtocols.Tls13), + $"CipherSuites = '{Tls12SuiteName}' was accepted at startup and the port then " + + "refused every client at both versions (no ciphers available): a TLS 1.2 suite " + + "named the IANA way is accepted into the TLS 1.3 list and leaves it empty"); + } + + // The other spelling of the same mistake, and the one that does not announce itself: + // a real 1.3 suite beside it. That port handshakes perfectly and offers ONE suite, not + // the two configured - the silent narrowing that the typo'd-name refusal exists to + // stop, arrived at through a name that passes the same check. + Built beside = Build(new TlsOptions + { + CertificatePath = cert, + KeyPath = key, + CipherSuites = $"TLS_AES_256_GCM_SHA384:{Tls12SuiteName}", + }); + + Assert.True(beside.Refusal is not null && beside.Refusal.Contains(Tls12SuiteName), + $"'{Tls12SuiteName}' beside a real 1.3 suite was accepted and then silently dropped " + + "from the list, which is what a one-character typo in a suite name is refused for"); + }, "found by review: SSL_CTX_set_ciphersuites resolves IANA std names over the whole cipher " + + "table, so a TLS 1.2 suite name passes every check this module makes and disables the " + + "TLS 1.3 list it lands in"); + + runner.Test("ctx: control: a list of real TLS 1.3 suite names starts and the port serves it", () => + { + // The guard on the refusal above: it must turn away a name that cannot be offered over + // TLS 1.3, and nothing else. A check that refused every list, or one that read the + // shape of the name rather than what OpenSSL does with it, would satisfy the Pending + // above and break every server that states a suite list at all. + (string cert, string key) = TestCert.Ensure(); + + Built built = Build(new TlsOptions + { + CertificatePath = cert, + KeyPath = key, + CipherSuites = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384", + }); + + Assert.True(built.Refusal is null, $"a list of real TLS 1.3 suites was refused: {built.Refusal}"); + Assert.True(Handshakes(built.Port, SslProtocols.Tls13), "the port did not serve a TLS 1.3 client"); + }); + + runner.Test("ctx: a MinProtocolVersion outside the enum is refused, not read as the weaker floor", () => + { + // No cast is needed to get here. Enum.Parse and the options binders accept ANY integer + // for an enum - Enum.Parse("3") succeeds - so a config that carries + // the floor as a number, or one written against a build where the enum has since grown + // a member, arrives with a value the ternary in Configure has never heard of. It maps + // everything that is not Tls13 to the TLS 1.2 floor, so the unknown value is resolved + // to the WEAKEST posture the setting can express, silently. + (string cert, string key) = TestCert.Ensure(); + + Built built = Build(new TlsOptions + { + CertificatePath = cert, + KeyPath = key, + MinProtocolVersion = (TlsProtocolVersion)3, + }); + + if (built.Refusal is { } refusal) + { + Assert.True(refusal.Contains("MinProtocolVersion"), + $"startup was refused, but not for the version floor: {refusal}"); + return; + } + + // It started. Whatever the caller meant by the value, they did not name Tls12 - and + // whether TLS 1.2 is on offer is the one thing this setting decides. + Assert.True(!Handshakes(built.Port, SslProtocols.Tls12), + "a MinProtocolVersion that is not one of the enum's members was accepted and the " + + "port then served a TLS 1.2 client: an unrecognised floor is resolved to the " + + "weaker of the two rather than refused"); + }); + + runner.Test("ctx: a negative HandshakeTimeoutMs is refused, not read as no sweep at all", () => + { + // TlsOptions documents ONE value as disabling the sweep, and it is zero. Both guards + // that read the setting test "> 0", so every negative value disables it too - the + // ticker is never registered and no handshake is ever enqueued. What is lost is the + // only bound on a peer that connects and then says nothing, which is the one part of a + // TLS server reachable before anything has been authenticated. + (string cert, string key) = TestCert.Ensure(); + + Built built = Build(new TlsOptions + { + CertificatePath = cert, + KeyPath = key, + HandshakeTimeoutMs = -1, + }); + + if (built.Refusal is { } refusal) + { + Assert.True(refusal.Contains("HandshakeTimeoutMs"), + $"startup was refused, but not for the timeout: {refusal}"); + return; + } + + // It started, so the sweep it configured has to run. A deadline already in the past + // means the first tick after the connection arrives is the one that closes it, so a + // budget of seconds is a backstop and not a stopwatch. + Assert.True(SilentPeerDropped(built.Port, 4_000), + "HandshakeTimeoutMs = -1 was accepted and a peer that sent nothing was still held " + + "4 s later: a negative value silently disables the sweep, which TlsOptions says " + + "only zero does"); + }); + } + + /// What became of a configuration: the port it built, or why it was refused. + /// + /// Both outcomes are legitimate answers here, which is why they are one value rather than a + /// helper that asserts on either. The refusal carries its message so that a test asserting one + /// can assert on the REASON - a port already held, or a reactor that died on the way up, would + /// otherwise read as the refusal being asked for. + /// + private readonly record struct Built(int Port, string? Refusal); + + private static Built Build(TlsOptions options) + { + try + { + return new Built(TestServer.Start(Handlers.Tls, r => TlsService.Start(r, options)), null); + } + catch (Exception e) + { + return new Built(0, e.Message); + } + } + + /// + /// Whether the handshake completed. A refusal is false; a server that HANGS throws, so a test + /// asserting a client was turned away cannot be satisfied by one that answers nobody at all. + /// + private static bool Handshakes(int port, SslProtocols protocols) + { + using var sock = new TcpClient(); + sock.Connect("127.0.0.1", port); + sock.ReceiveTimeout = 6_000; + sock.SendTimeout = 6_000; + + using var ssl = new SslStream(sock.GetStream(), false, (_, _, _, _) => true); + + try + { + ssl.AuthenticateAsClient("localhost", null, protocols, false); + return true; + } + catch (AuthenticationException) + { + return false; // an alert: declined, which is what these tests mean by refused + } + catch (IOException e) when (TimedOut(e)) + { + throw new Exception( + $"the server on :{port} neither completed nor refused a handshake within 6 s - a " + + "hang is not a refusal, and this assertion would have read it as one.", e); + } + catch (IOException) + { + return false; // closed without an alert: rude, but still declined + } + } + + /// + /// Whether the server gave up on a peer that connects and sends nothing, inside + /// . False means the connection was still open at the end of it. + /// + private static bool SilentPeerDropped(int port, int budgetMs) + { + using var client = new TcpClient(); + client.Connect("127.0.0.1", port); + client.ReceiveTimeout = budgetMs; + + // Not one byte of ClientHello ever goes out. + try + { + return client.GetStream().Read(new byte[16], 0, 16) == 0; // FIN: the server let go + } + catch (IOException e) when (TimedOut(e)) + { + return false; // the CLIENT gave up first, so the server is still holding it + } + catch (IOException) + { + return true; // a reset rather than an orderly close - still the server giving up + } + } + + private static bool TimedOut(Exception e) + { + for (Exception? at = e; at is not null; at = at.InnerException) + { + if (at is SocketException { SocketErrorCode: SocketError.TimedOut }) + { + return true; + } + } + + return false; } } diff --git a/tests/Ioxide.Tests.Tls/IdentitySubjectTests.cs b/tests/Ioxide.Tests.Tls/IdentitySubjectTests.cs index 431b618b..9337dc04 100644 --- a/tests/Ioxide.Tests.Tls/IdentitySubjectTests.cs +++ b/tests/Ioxide.Tests.Tls/IdentitySubjectTests.cs @@ -1,23 +1,437 @@ +using System.Formats.Asn1; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; using ioxide; using ioxide.tls; namespace Ioxide.Tests; /// -/// How a peer's name is derived from its certificate: several CNs, a very long DN, and the -/// difference between the rendered subject and the structural CN. +/// How a peer's name is derived from its certificate: several common names, a very long DN, and +/// the difference between the rendered subject and the structural CN. +/// +/// Every fixture here is a certificate the mutual-TLS CA legitimately signed and the server +/// legitimately accepted - the handshake completes and the chain validates in all of them. What is +/// under test is what the two accessors then SAY about the peer, which is the whole of what a +/// handler has to authorise and log on. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. -/// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// The subjects are assembled as DER, and the certificates signed by hand, because the interesting +/// shapes cannot be spelled. CertificateRequest takes a subject STRING, and .NET's parser +/// for one has no syntax for the ASN.1 string type an attribute is encoded as - which is what the +/// pending case below turns on - nor will .NET load a certificate holding a type it cannot decode, +/// so even a hand-built DN cannot be issued through it. /// internal static class IdentitySubjectTests { public static void Register(Runner runner) { + runner.Test("identity: a subject with several common names is read as the last one", () => + { + // Which end of a multi-CN subject is "the" CN is a real disagreement in the wild: the + // natural OpenSSL one-liner (X509_NAME_get_index_by_NID with -1) answers with the + // FIRST. This pins the answer ioxide gives, and pins it against the reading .NET makes + // of the very same certificate - the one an ASP.NET application sees, because + // ITlsConnectionFeature.ClientCertificate is built from PeerCertificateDer. Two + // accessors on one connection that named different principals would be a defect; they + // agree, and this is what says so. + Identity id = Ask("multi-cn", withDotNetName: true, subjectDer: Dn( + [Attribute(CommonName, UniversalTagNumber.UTF8String, "first.example")], + [Attribute(CommonName, UniversalTagNumber.UTF8String, "second.example")])); + + Assert.True(id.Subject.Contains("/CN=first.example/CN=second.example"), + $"the fixture must really carry both names in that order, got: {id.Subject}"); + Assert.Equal("second.example", id.CommonName); + Assert.Equal(id.CommonName, id.DotNetName); + }); + + runner.Test("identity: a 40 KB distinguished name is rendered whole, common name included", () => + { + // X509_NAME_oneline's buffer-taking form reports success when handed a buffer too + // small and writes as many whole attributes as fit - a valid-looking prefix with the + // CN missing, which is one string for two principals. RenderSubject uses the + // allocating form to avoid exactly that, and this is what says the avoidance holds at + // a size no real DN reaches. Larger is not testable through a handshake: at 200 KB the + // server refuses the certificate message outright (excessive message size). + Identity id = Ask("long-dn", Dn( + [Attribute(Organisation, UniversalTagNumber.UTF8String, new string('x', 40_000))], + [Attribute(CommonName, UniversalTagNumber.UTF8String, "tail.example")])); + + // The length is asserted first: without it a subject truncated to nothing would still + // satisfy the CN assertion, because the CN comes from the DN and not from the render. + Assert.True(id.SubjectLength > 40_000, + $"the whole DN should be rendered, got {id.SubjectLength} characters"); + Assert.True(id.Subject.EndsWith("/CN=tail.example", StringComparison.Ordinal), + $"the rendered subject should end with the CN, got the last 40: {Tail(id.Subject)}"); + Assert.Equal("tail.example", id.CommonName); + }); + + runner.Test("identity: a common name carrying CRLF is refused, and the subject escapes it", () => + { + // The pair the refusal rests on. PeerCommonName is documented as the value to compare, + // and logging it is what callers do next to comparing it, so a CN that could forge a + // log line or split a header is not reported as a name at all. PeerSubject may carry + // it because the render escapes it. If either half changes, one of these two fails. + Identity id = Ask("crlf-cn", Dn( + [Attribute(CommonName, UniversalTagNumber.UTF8String, "alice\r\nauthorized=root")])); + + Assert.True(id.CommonName is null, + $"a CN carrying CR/LF must not be reported as an identity, got: {id.CommonName}"); + + // Not merely non-null: the peer authenticated, so a subject exists, and it carries + // those bytes in OpenSSL's escaped form rather than as real control characters. + Assert.True(id.Subject.Contains(@"alice\x0D\x0Aauthorized=root", StringComparison.Ordinal), + $"the rendered subject should escape the CR/LF, got: {id.Subject}"); + }); + + runner.Test("identity: a CN the decoder refuses is not named, even where the rendered subject cannot tell it apart", () => + { + // Both certificates are signed by the same CA and both handshakes complete, so both + // peers are authenticated. They differ in ONE thing: the ASN.1 string type the common + // name is encoded as. + // + // PrintableString is the ordinary one. A BIT STRING is a type OpenSSL accepts inside a + // Name - it is in the mask X509_NAME's template parses with - but will not decode to + // text, so ExtractCommonName's ASN1_STRING_to_UTF8 fails and ioxide reports no name at + // all. The render is not so careful: X509_NAME_oneline copies the attribute's bytes + // out as they are, so the forged certificate renders character for character as the + // real one's subject. + Identity real = Ask("printable-cn", withDotNetName: true, subjectDer: Dn( + [Attribute(CommonName, UniversalTagNumber.PrintableString, "audit-alice")])); + + Identity forged = Ask("bitstring-cn", withDotNetName: true, subjectDer: Dn( + [Attribute(CommonName, BitString, "\0audit-alice")])); + + // Guards, so the comparison below cannot be satisfied by a fixture that never arrived. + // Both peers were validated: a chain that does not build fails the handshake, and + // neither accessor is populated unless SSL_get_verify_result said X509_V_OK. + Assert.Equal("audit-alice", real.CommonName); + Assert.True(forged.CommonName is null, + $"the forged CN does not decode and must not be reported, got: {forged.CommonName}"); + Assert.True(real.SubjectLength > 0 && forged.SubjectLength > 0, + "both peers authenticated, so both must have a rendered subject"); + Assert.True(real.Subject.Length == real.SubjectLength && forged.Subject.Length == forged.SubjectLength, + "both subjects must arrive whole for a comparison of them to mean anything"); + + // The two DO render alike, and that is why the assertion above is the one that matters. + // RenderSubject copies an attribute's bytes out verbatim, so a CN carried in an ASN.1 + // type ExtractCommonName refuses still renders as an ordinary name: X509_NAME_oneline + // is a display function and cannot separate these two principals. Reviewed as a defect + // and kept, because PeerSubject is documented for exactly one use - "rendered for + // people: logs, audit trails, error messages" - and its own remarks tell the caller not + // to authorize on a substring of it and to use PeerCommonName, which fails closed here. + // A trusted CA that will sign a hand-assembled DER Name could equally issue the plain + // CN, so the encoding buys an attacker nothing the CA had not already granted. + Assert.True(real.Subject == forged.Subject, + "the two subjects no longer collide - if RenderSubject learned to tell these apart, " + + "this test should become the stronger claim that they differ"); + }); + } + + // ---- talking to a server ------------------------------------------------------------------ + + /// + /// What the two accessors said about one connection. is elided in + /// the middle when it is very long, which never is. + /// + private readonly record struct Identity( + string? CommonName, string Subject, int SubjectLength, string? DotNetName); + + /// + /// Start a server that reports both accessors, hand it a client certificate carrying + /// as its subject, and return what it said. + /// + private static Identity Ask(string tag, byte[] subjectDer, bool withDotNetName = false) + { + (string ca, string serverCert, string serverKey, _, _, _, _) = TestCert.EnsureMutualTls(); + (string certPath, string keyPath) = MintClient(tag, subjectDer); + + int port = TestServer.Start((r, c) => Report(r, c, withDotNetName), r => TlsService.Start(r, new TlsOptions + { + CertificatePath = serverCert, + KeyPath = serverKey, + ClientCaPath = ca, + + // Required rather than optional, so a certificate that failed to validate could not be + // answered at all: every assertion here is about a peer that authenticated. + RequireClientCertificate = true, + })); + + // The deadline is generous rather than tight: nothing here asserts on time, it only keeps + // a wedged read from waiting out the runner's watchdog. + (int status, string body) = Client.GetTlsClientCert(port, "/who", certPath, keyPath, timeoutMs: 20_000); + Assert.Equal(200, status); + + return new Identity( + Unwrap(Field(body, "cn")), + Field(body, "subject"), + int.Parse(Field(body, "subjectlen")), + Unwrap(Field(body, "net"))); + } + + /// + /// Reports both accessors, plus what .NET makes of the same certificate - the reading an + /// ASP.NET application gets, since ITlsConnectionFeature.ClientCertificate is built from + /// . + /// + private static async Task Report(Reactor reactor, TcpConnection connection, bool withDotNetName) + { + TlsSession? session = null; + try + { + session = await reactor.GetService()!.AcceptAsync(connection); + + // The request routinely rides in with the handshake's final flight - a TLS 1.3 client + // sends it straight after Finished - and those bytes are decrypted and gone from the + // socket before AcceptAsync returns. Answering that one before parking on a read is + // what keeps this from waiting for bytes that already arrived; the big-DN fixture hits + // it often enough to have shown up as an intermittent timeout while it was missing. + if (!session.DrainPlaintext().IsEmpty) + { + Answer(connection, session, withDotNetName); + await connection.FlushAsync(); + } + + // No ResetRead above: that belongs to a read this handler actually issued. + while (true) + { + RecvSnapshot snapshot = await connection.ReadAsync(); + if (snapshot.IsClosed) + { + return; + } + + Answer(connection, session, withDotNetName); + await connection.FlushAsync(); + connection.ResetRead(); + } + } + finally + { + session?.Dispose(); + connection.DecRef(); + } + } + + private static void Answer(TcpConnection connection, TlsSession session, bool withDotNetName) + { + // Costs a full certificate parse on the reactor thread, and one of these certificates is + // 41 KB - so it is only done for the tests whose claim is about the two readings agreeing. + string? dotnet = null; + if (withDotNetName && session.PeerCertificateDer is { } der) + { + using X509Certificate2 certificate = X509CertificateLoader.LoadCertificate(der); + dotnet = certificate.GetNameInfo(X509NameType.SimpleName, false); + } + + byte[] body = Encoding.ASCII.GetBytes( + $"cn={Escape(session.PeerCommonName)}\n" + + $"subject={Escape(Elide(session.PeerSubject))}\n" + + $"subjectlen={session.PeerSubject?.Length ?? -1}\n" + + $"net={Escape(dotnet)}\n"); + + session.Write(connection, [ + .. Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\ncontent-length: {body.Length}\r\n\r\n"), + .. body]); + } + + // The response has to fit the harness server's 16 KB write slab, and one of these subjects is + // 40 KB. Only the ends of a long one are sent - the length travels separately and unabridged, + // and a test that compares subjects whole asserts it got an unelided one. + private static string? Elide(string? subject) + => subject is null || subject.Length <= 160 ? subject : subject[..80] + "[...]" + subject[^80..]; + + // A name can hold anything and this one travels back in the response body, which the client + // reads as ASCII lines: a raw CR would end the line the reader splits on, and a null name has + // to stay distinguishable from an empty one. + private const string None = "(none)"; + + private static string Escape(string? value) + { + if (value is null) + { + return None; + } + + var encoded = new StringBuilder(value.Length); + foreach (char c in value) + { + encoded.Append(c is >= ' ' and <= '~' && c != '%' ? c.ToString() : $"%{(int)c:X4}"); + } + + return encoded.ToString(); + } + + private static string? Unwrap(string field) => field == None ? null : field; + + private static string Field(string body, string name) + { + foreach (string line in body.Split('\n')) + { + if (line.StartsWith($"{name}=", StringComparison.Ordinal)) + { + return line[(name.Length + 1)..]; + } + } + + throw new Exception($"the handler reported no '{name}' - the body was: {body}"); + } + + private static string Tail(string value) => value.Length <= 40 ? value : value[^40..]; + + // ---- fixtures ----------------------------------------------------------------------------- + + private const string CommonName = "2.5.4.3"; + private const string Organisation = "2.5.4.10"; + + /// + /// An ASN.1 tag written as itself, for a type will not write as a + /// character string. 3 is BIT STRING, which a Name may carry and text decoding refuses. + /// + private const UniversalTagNumber BitString = (UniversalTagNumber)3; + + private static (string Oid, UniversalTagNumber Tag, string Value) Attribute( + string oid, UniversalTagNumber tag, string value) => (oid, tag, value); + + /// A Name, from RDNs, each a set of attributes. + private static byte[] Dn(params (string Oid, UniversalTagNumber Tag, string Value)[][] rdns) + { + var writer = new AsnWriter(AsnEncodingRules.DER); + using (writer.PushSequence()) + { + foreach ((string Oid, UniversalTagNumber Tag, string Value)[] rdn in rdns) + { + using (writer.PushSetOf()) + { + foreach ((string oid, UniversalTagNumber tag, string value) in rdn) + { + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier(oid); + WriteValue(writer, tag, value); + } + } + } + } + } + + return writer.Encode(); + } + + private static void WriteValue(AsnWriter writer, UniversalTagNumber tag, string value) + { + if (tag == BitString) + { + // Written as a raw tag-length-value, because the point of this attribute is to be a + // type the writer's character-string path would never produce. Latin-1 so that one + // char is one byte, and the caller supplies the leading unused-bit count itself. + byte[] content = Encoding.Latin1.GetBytes(value); + writer.WriteEncodedValue([(byte)tag, (byte)content.Length, .. content]); + return; + } + + writer.WriteCharacterString(tag, value); + } + + /// + /// A client certificate for an arbitrary subject, signed by the CA + /// writes. Named per process, so suites running + /// concurrently never overwrite one another's certificate with another one's key. + /// + private static (string CertPath, string KeyPath) MintClient(string tag, byte[] subjectDer) + { + (string ca, _, _, _, _, _, _) = TestCert.EnsureMutualTls(); + + string dir = Path.Combine(Path.GetTempPath(), "ioxide-identity-subject"); + Directory.CreateDirectory(dir); + + string certPath = Path.Combine(dir, $"{tag}-{Environment.ProcessId}.crt"); + string keyPath = Path.Combine(dir, $"{tag}-{Environment.ProcessId}.key"); + + using X509Certificate2 caCert = X509Certificate2.CreateFromPemFile(ca, Path.ChangeExtension(ca, ".key")); + using RSA caKey = caCert.GetRSAPrivateKey()!; + using var key = RSA.Create(2048); + + byte[] der = BuildCertificate(subjectDer, key, caCert, caKey); + + File.WriteAllText(certPath, "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"); + File.WriteAllText(keyPath, key.ExportPkcs8PrivateKeyPem()); + + return (certPath, keyPath); + } + + /// + /// Assemble and sign a leaf by hand, because CertificateRequest cannot carry these + /// subjects: it parses the certificate it produces, and .NET refuses to load one whose DN + /// holds a string type it will not decode. + /// + /// + /// The window sits inside the CA's own at both ends - .NET refuses to issue a leaf that starts + /// before its issuer does, and the fixture CA starts a day ago and runs for two years. + /// + private static byte[] BuildCertificate(byte[] subject, RSA key, X509Certificate2 issuer, RSA issuerKey) + { + var tbs = new AsnWriter(AsnEncodingRules.DER); + using (tbs.PushSequence()) + { + using (tbs.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0, true))) + { + tbs.WriteInteger(2); // v3 + } + + tbs.WriteInteger(Random.Shared.NextInt64(1, long.MaxValue)); + SignatureAlgorithm(tbs); + tbs.WriteEncodedValue(issuer.SubjectName.RawData); + + using (tbs.PushSequence()) + { + tbs.WriteUtcTime(DateTimeOffset.UtcNow.AddMinutes(-5)); + tbs.WriteUtcTime(DateTimeOffset.UtcNow.AddDays(30)); + } + + tbs.WriteEncodedValue(subject); + tbs.WriteEncodedValue(key.ExportSubjectPublicKeyInfo()); + + using (tbs.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 3, true))) + using (tbs.PushSequence()) + using (tbs.PushSequence()) + { + // clientAuth, and not decoration: the server verifies a client chain with + // OpenSSL's ssl_client purpose, which refuses a leaf that does not carry it. + tbs.WriteObjectIdentifier("2.5.29.37"); + var eku = new AsnWriter(AsnEncodingRules.DER); + using (eku.PushSequence()) + { + eku.WriteObjectIdentifier("1.3.6.1.5.5.7.3.2"); + } + + tbs.WriteOctetString(eku.Encode()); + } + } + + byte[] tbsBytes = tbs.Encode(); + + var certificate = new AsnWriter(AsnEncodingRules.DER); + using (certificate.PushSequence()) + { + certificate.WriteEncodedValue(tbsBytes); + SignatureAlgorithm(certificate); + certificate.WriteBitString( + issuerKey.SignData(tbsBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + } + + return certificate.Encode(); + + static void SignatureAlgorithm(AsnWriter writer) + { + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier("1.2.840.113549.1.1.11"); // sha256WithRSAEncryption + writer.WriteNull(); + } + } } } diff --git a/tests/Ioxide.Tests.Tls/PrologueReaderTests.cs b/tests/Ioxide.Tests.Tls/PrologueReaderTests.cs index d7015600..18ce54de 100644 --- a/tests/Ioxide.Tests.Tls/PrologueReaderTests.cs +++ b/tests/Ioxide.Tests.Tls/PrologueReaderTests.cs @@ -1,4 +1,6 @@ -using ioxide; +using System.Buffers; +using System.IO.Pipelines; +using System.Text; using ioxide.tls; namespace Ioxide.Tests; @@ -8,16 +10,389 @@ namespace Ioxide.Tests; /// reader over an in-memory inner PipeReader, which is the only way most of it is testable at all. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// Everything here drives the reader directly over a : no reactor, no socket, no +/// certificate. That is the point of the file - end to end this class is only reachable with +/// KernelRx on, which needs the kernel module AND a session that negotiated a cipher the kernel +/// can take over, so the index arithmetic it is made of would otherwise only ever be exercised by +/// accident. Driving it directly pins each property on its own. /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// The one thing an in-memory inner reader is NOT is the real one: TcpConnectionPipeReader hands +/// out unmanaged ring memory and reports a closed connection rather than a completed writer. Where +/// that difference could matter the assertion says so. /// internal static class PrologueReaderTests { + /// The reader's own bound on how far the carry may grow. Mirrored, not imported. + private const int MaxCarryBytes = 1 << 20; + public static void Register(Runner runner) { + RegisterCarry(runner); + RegisterCompletion(runner); + RegisterTeardown(runner); + RegisterBound(runner); + } + + // ---------------------------------------------------------------- serving the carry + + private static void RegisterCarry(Runner runner) + { + // The positions in a ReadResult built over carry[9..] report 9, not 0 - they are absolute + // offsets into the backing array. AdvanceTo therefore ASSIGNS _consumed; adding would + // double-count every partial consume after the first and skip the bytes in between, which + // is silent: the caller just never sees them. + runner.Test("prologue: a partial consume resumes where it left off, not past it", () => + { + Pipe pipe = NewPipe(); + var reader = new TlsProloguePipeReader(pipe.Reader, Ascii("ABCDEFGHIJ")); + var seen = new StringBuilder(); + + ReadResult read = Await(reader.ReadAsync()); + Assert.Equal("ABCDEFGHIJ", Text(read.Buffer)); + seen.Append(Text(read.Buffer.Slice(0, 4))); + reader.AdvanceTo(read.Buffer.GetPosition(4)); + + read = Await(reader.ReadAsync()); + Assert.Equal("EFGHIJ", Text(read.Buffer)); + seen.Append(Text(read.Buffer.Slice(0, 3))); + reader.AdvanceTo(read.Buffer.GetPosition(3)); + + // Guard the shape of the failure as well as the content: a double-counting AdvanceTo + // lands past the end and releases here, so this reader would already be a pass-through + // with "HIJ" dropped on the floor. + Assert.True(!reader.Drained, "three of ten bytes are still unconsumed, but the carry was already released"); + + read = Await(reader.ReadAsync()); + Assert.Equal("HIJ", Text(read.Buffer)); + seen.Append(Text(read.Buffer)); + reader.AdvanceTo(read.Buffer.End); + + Assert.Equal("ABCDEFGHIJ", seen.ToString()); + Assert.True(reader.Drained, "the carry was consumed to the end and should have been released"); + }); + + // Examined to the end without consuming means "wake me when there is MORE" - the whole + // reason PipeReader has a two-argument AdvanceTo. Handing the identical buffer straight + // back satisfies the letter of ReadAsync and spins the caller at 100% of a reactor core. + runner.Test("prologue: examining the whole carry without consuming waits for more bytes", () => + { + Pipe pipe = NewPipe(); + var reader = new TlsProloguePipeReader(pipe.Reader, Ascii("ABCDE")); + + ReadResult read = Await(reader.ReadAsync()); + Assert.Equal("ABCDE", Text(read.Buffer)); + reader.AdvanceTo(read.Buffer.Start, read.Buffer.End); + + // Nothing has been written to the pipe, so this can only complete by replaying bytes + // the caller has already said are not enough. + ValueTask parked = reader.ReadAsync(); + Assert.True(!parked.IsCompleted, "the read returned the same examined bytes again instead of waiting: a hot spin"); + + Await(pipe.Writer.WriteAsync(Ascii("FGH"))); + read = Await(parked); + Assert.Equal("ABCDEFGH", Text(read.Buffer)); + + // And a partial consume carried across the append: the unconsumed prefix survives + // compaction and the new bytes land behind it, in order. + reader.AdvanceTo(read.Buffer.GetPosition(2), read.Buffer.End); + parked = reader.ReadAsync(); + Assert.True(!parked.IsCompleted, "the same replay, one compaction later"); + + Await(pipe.Writer.WriteAsync(Ascii("IJ"))); + read = Await(parked); + Assert.Equal("CDEFGHIJ", Text(read.Buffer)); + reader.AdvanceTo(read.Buffer.End); + }); + + // It is a startup detour, not a pump: once the caller has consumed past the carry every + // later read is the inner reader's own, uncopied. + runner.Test("prologue: the carry drains exactly once and the reader turns into a pass-through", () => + { + Pipe pipe = NewPipe(); + var reader = new TlsProloguePipeReader(pipe.Reader, Ascii("PRI * HTTP/2.0")); + + Assert.True(!reader.Drained, "nothing has consumed the carry yet"); + + ReadResult read = Await(reader.ReadAsync()); + Assert.Equal("PRI * HTTP/2.0", Text(read.Buffer)); + reader.AdvanceTo(read.Buffer.End); + Assert.True(reader.Drained, "a fully consumed carry releases"); + + Await(pipe.Writer.WriteAsync(Ascii("ring-bytes"))); + read = Await(reader.ReadAsync()); + Assert.Equal("ring-bytes", Text(read.Buffer)); // not the prologue a second time + reader.AdvanceTo(read.Buffer.End); + + // Pass-through means the inner reader's completion is the one the caller sees. + pipe.Writer.Complete(); + read = Await(reader.ReadAsync()); + Assert.True(read.IsCompleted, "the inner reader's completion did not reach the caller after the drain"); + Assert.Equal(0L, read.Buffer.Length); + reader.AdvanceTo(read.Buffer.End); + }); + } + + // ------------------------------------------------------------------- completion state + + private static void RegisterCompletion(Runner runner) + { + // Found by review, not by a failure in the field: the carry fast path in ReadAsync + // hard-codes isCompleted:false, while the appending path propagates the inner reader's + // flag. A partial consume after the peer is gone therefore takes the fast path and reports + // the stream open again. IsCompleted is monotonic in System.IO.Pipelines - a completed + // writer cannot un-complete - and a caller that latched on it to decide "this request is + // truncated, stop waiting" is being told the opposite one read later. + runner.Pending("prologue: IsCompleted does not go back to false once the peer is gone", () => + { + Pipe pipe = NewPipe(); + var reader = new TlsProloguePipeReader(pipe.Reader, Ascii("ABCDEFGHIJ")); + + ReadResult read = Await(reader.ReadAsync()); + reader.AdvanceTo(read.Buffer.Start, read.Buffer.End); // examined it all, consumed none + + Await(pipe.Writer.WriteAsync(Ascii("KLMNO"))); + pipe.Writer.Complete(); // the peer is gone + + read = Await(reader.ReadAsync()); + Assert.Equal("ABCDEFGHIJKLMNO", Text(read.Buffer)); + Assert.True(read.IsCompleted, "the inner reader reported the writer gone"); + + reader.AdvanceTo(read.Buffer.GetPosition(5)); // one message off the front + + read = Await(reader.ReadAsync()); + Assert.Equal("FGHIJKLMNO", Text(read.Buffer)); + Assert.True(read.IsCompleted, + "IsCompleted flapped true then false: the reader un-completed a stream whose peer is already gone"); + reader.AdvanceTo(read.Buffer.End); + }, "TlsProloguePipeReader.ReadAsync hard-codes isCompleted:false on the carry fast path and never " + + "latches what the inner read reported, so any partial consume after the writer completed " + + "reports the stream as open again"); + + // The control for the Pending above, and the reason it is a defect rather than a taste: + // the very reader being wrapped, driven through the identical sequence, keeps the flag. + // Without this the Pending could be nothing but an unfair drive sequence. + runner.Test("prologue: control: the Pipe being wrapped keeps IsCompleted true across the same reads", () => + { + Pipe pipe = NewPipe(); + PipeReader reader = pipe.Reader; + + Await(pipe.Writer.WriteAsync(Ascii("ABCDEFGHIJ"))); + ReadResult read = Await(reader.ReadAsync()); + Assert.Equal("ABCDEFGHIJ", Text(read.Buffer)); + reader.AdvanceTo(read.Buffer.Start, read.Buffer.End); + + Await(pipe.Writer.WriteAsync(Ascii("KLMNO"))); + pipe.Writer.Complete(); + + read = Await(reader.ReadAsync()); + Assert.Equal("ABCDEFGHIJKLMNO", Text(read.Buffer)); + Assert.True(read.IsCompleted, "the Pipe should report the completed writer"); + + reader.AdvanceTo(read.Buffer.GetPosition(5)); + + read = Await(reader.ReadAsync()); + Assert.Equal("FGHIJKLMNO", Text(read.Buffer)); + Assert.True(read.IsCompleted, "a plain Pipe latches IsCompleted; that is the bar the wrapper misses"); + reader.AdvanceTo(read.Buffer.End); + }); + } + + // ---------------------------------------------------------------- cancel and teardown + + private static void RegisterTeardown(Runner runner) + { + // A cancel raised while the carry is live is served HERE. Latching it into the inner + // reader instead would hold it until the carry drains and then pop it out as a wake-up + // nobody asked for, on a read that had nothing to do with it. + runner.Test("prologue: a cancel raised while the carry is live is served once, and by the carry", () => + { + Pipe pipe = NewPipe(); + var reader = new TlsProloguePipeReader(pipe.Reader, Ascii("ABCDE")); + + reader.CancelPendingRead(); + + ReadResult read = Await(reader.ReadAsync()); + Assert.True(read.IsCanceled, "the cancel was not served by the carry"); + Assert.Equal("ABCDE", Text(read.Buffer)); // and it did not eat the bytes + reader.AdvanceTo(read.Buffer.Start, read.Buffer.Start); + + read = Await(reader.ReadAsync()); + Assert.True(!read.IsCanceled, "the cancel was served twice"); + Assert.Equal("ABCDE", Text(read.Buffer)); + reader.AdvanceTo(read.Buffer.End); + Assert.True(reader.Drained, "the carry was consumed to the end"); + + Await(pipe.Writer.WriteAsync(Ascii("Z"))); + read = Await(reader.ReadAsync()); + Assert.True(!read.IsCanceled, "the cancel resurfaced from the inner reader after the drain"); + Assert.Equal("Z", Text(read.Buffer)); + reader.AdvanceTo(read.Buffer.End); + }); + + // The other half of that: a cancel that was never served has to travel with the drain, + // because from there on reads go straight to the inner reader and would never see it. + runner.Test("prologue: a cancel raised while the carry is live survives the drain", () => + { + Pipe pipe = NewPipe(); + var reader = new TlsProloguePipeReader(pipe.Reader, Ascii("ABCDE")); + + ReadResult read = Await(reader.ReadAsync()); + reader.CancelPendingRead(); // nobody is parked; the carry would serve it + reader.AdvanceTo(read.Buffer.End); // but the caller drains it first + Assert.True(reader.Drained, "the carry was consumed to the end"); + + // The pipe is empty and its writer is open, so this can only complete if the cancel + // travelled. Checked synchronously: nothing else can write. + ValueTask next = reader.ReadAsync(); + Assert.True(next.IsCompleted, "the cancel was dropped by the drain: the caller is parked on a read it already cancelled"); + + read = Await(next); + Assert.True(read.IsCanceled, "the read completed, but not as a cancellation"); + reader.AdvanceTo(read.Buffer.Start, read.Buffer.Start); + }); + + // Complete arrives with bytes still in the carry on every abrupt teardown: a handler that + // answers from the request head and returns, a decrypt fault, a reset. The pooled array has + // to go back and the inner reader has to learn the read side is gone. + runner.Test("prologue: Complete with the carry still live releases it and completes the inner reader", () => + { + Pipe pipe = NewPipe(); + var reader = new TlsProloguePipeReader(pipe.Reader, Ascii("ABCDE")); + + ReadResult read = Await(reader.ReadAsync()); + reader.AdvanceTo(read.Buffer.Start, read.Buffer.Start); // consumed nothing: still live + Assert.True(!reader.Drained, "the carry is still live"); + + reader.Complete(); + Assert.True(reader.Drained, "Complete left the carry held"); + reader.Complete(); // idempotent, and must not hand the array back a second time + + FlushResult flush = Await(pipe.Writer.WriteAsync(Ascii("x"))); + Assert.True(flush.IsCompleted, "Complete did not reach the inner reader: a writer flushing into it never learns the read side is gone"); + }); + + // A double return puts one array in the pool twice and the next two rents of that size + // class hand out the SAME instance. Large size class on purpose: the runner gives each + // test its own thread, so the shared pool's per-thread cache for that bucket starts empty + // and nothing else in the process is trading 256 KiB arrays inside this window. + // + // This can under-detect - the pool is free to serve the second rent from somewhere else - + // but it cannot report a double return that did not happen. + runner.Test("prologue: the pooled carry goes back to the pool exactly once", () => + { + const int size = 200_000; + byte[] prologue = new byte[size]; + prologue[0] = 0xAB; + prologue[size - 1] = 0xCD; + + Pipe pipe = NewPipe(); + var reader = new TlsProloguePipeReader(pipe.Reader, prologue); + + ReadResult read = Await(reader.ReadAsync()); + Assert.Equal((long)size, read.Buffer.Length); + reader.AdvanceTo(read.Buffer.End); // release + Assert.True(reader.Drained, "the carry was consumed to the end"); + reader.Complete(); // would release a second time if it still held one + + byte[] first = ArrayPool.Shared.Rent(size); + byte[] second = ArrayPool.Shared.Rent(size); + try + { + // Return does not clear, so the carry comes back still carrying the prologue. If + // this is some other array the carry was never returned at all and every kTLS-RX + // connection leaks one. + Assert.True(first[0] == 0xAB && first[size - 1] == 0xCD, + "the carry array never came back to the pool"); + Assert.True(!ReferenceEquals(first, second), + "the carry array went back to the pool twice: two rents handed out one array"); + } + finally + { + ArrayPool.Shared.Return(first); + ArrayPool.Shared.Return(second); + } + }); + } + + // -------------------------------------------------------------------- the carry bound + + private static void RegisterBound(Runner runner) + { + // While the carry is live this class copies ring bytes in and hands the ring buffers + // straight back, so there is nothing left to apply backpressure with. A peer that sends a + // partial head and then dribbles forever keeps the caller examining without consuming, and + // the carry grew for the life of the connection. It is bounded now, and the bound is the + // kind of thing a later refactor drops without noticing, because nothing legitimate hits it. + runner.Test("prologue: a carry that never gets consumed faults instead of growing without bound", () => + { + Pipe pipe = NewPipe(); + var reader = new TlsProloguePipeReader(pipe.Reader, Ascii("GET / HTTP/1.1\r\n")); + byte[] chunk = new byte[64 * 1024]; + long fed = 0; + + Assert.Throws(() => + { + // Four times the bound. Reached without a fault, this loop is the unbounded growth + // itself rather than a test of it. + for (int i = 0; i < 64; i++) + { + ReadResult read = Await(reader.ReadAsync()); + reader.AdvanceTo(read.Buffer.Start, read.Buffer.End); // examined, never consumed + Await(pipe.Writer.WriteAsync(chunk)); + fed += chunk.Length; + } + }, "refusing to buffer more"); + + Assert.True(fed <= MaxCarryBytes + chunk.Length, + $"the carry took {fed} bytes before it faulted, well past the {MaxCarryBytes}-byte bound"); + }); + + // The control, and the reason the bound is generous rather than tight: examining a whole + // request head without consuming it is exactly what a legitimate caller does, and here it + // does it one byte at a time - the append and compaction paths in their worst shape. + // Faulting this caller would break the case the class exists for. + runner.Test("prologue: control: an ordinary head examined byte by byte never reaches the bound", () => + { + byte[] preface = Ascii("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + byte[] head = Ascii("GET / HTTP/1.1\r\nHost: ioxide\r\nAccept: */*\r\n\r\n"); + + Pipe pipe = NewPipe(); + var reader = new TlsProloguePipeReader(pipe.Reader, preface); + + for (int i = 0; i < head.Length; i++) + { + ReadResult examined = Await(reader.ReadAsync()); + reader.AdvanceTo(examined.Buffer.Start, examined.Buffer.End); + Await(pipe.Writer.WriteAsync(head.AsMemory(i, 1))); + } + + ReadResult read = Await(reader.ReadAsync()); + Assert.Equal(Text(preface) + Text(head), Text(read.Buffer)); + reader.AdvanceTo(read.Buffer.End); + Assert.True(reader.Drained, "the whole head was consumed, so the carry should be gone"); + }); } + + // ------------------------------------------------------------------------- plumbing + + /// + /// An inner reader with backpressure far out of the way: these tests drive the prologue + /// reader, and a Pipe that paused its writer at 64 KB would be testing the Pipe instead. + /// + private static Pipe NewPipe() => new(new PipeOptions( + pauseWriterThreshold: 8L * 1024 * 1024, + resumeWriterThreshold: 4096, + useSynchronizationContext: false)); + + private static byte[] Ascii(string value) => Encoding.ASCII.GetBytes(value); + + private static string Text(ReadOnlySequence value) => Encoding.ASCII.GetString(value.ToArray()); + + private static string Text(byte[] value) => Encoding.ASCII.GetString(value); + + /// + /// Test bodies are synchronous, and blocking here is safe: the pipe has no synchronization + /// context and every write that releases a parked read is made from this same thread. + /// + private static T Await(ValueTask task) => task.GetAwaiter().GetResult(); } diff --git a/tests/Ioxide.Tests.Tls/SessionLifetimeTests.cs b/tests/Ioxide.Tests.Tls/SessionLifetimeTests.cs index 93e97a0a..9d2844dd 100644 --- a/tests/Ioxide.Tests.Tls/SessionLifetimeTests.cs +++ b/tests/Ioxide.Tests.Tls/SessionLifetimeTests.cs @@ -1,23 +1,186 @@ using ioxide; using ioxide.tls; +using ioxide.utils; namespace Ioxide.Tests; /// -/// TlsSession's lifetime: disposal ordering, double disposal, and what its public entry points do -/// after it has been disposed. +/// 's lifetime: it is a public disposable a user holds, so what its entry +/// points do once has freed the native SSL* and both BIOs is part +/// of its contract - whether stated or not. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// Two findings, from probing a real session obtained through the handshake and then poked after +/// disposal (see the DIAG history in git if reproducing): /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// 1. Double disposal is SAFE and enforced: the _disposed flag short-circuits the second +/// call, so the SSL* is freed once. Pinned below as an ordinary - a +/// regression that dropped the guard would double-free. This is the control: the type DOES guard +/// one thing, which is why the absence of any guard on the rest is a choice, not an oversight. +/// +/// 2. Use-after-dispose is NOT refused: , +/// and the version getters dereference the freed handles with +/// no disposed-guard. Reproduced below as a . A guarded type would +/// answer with ; this one runs SSL_write against freed +/// memory and surfaces whatever OpenSSL makes of it. +/// +/// A THIRD shape is real but deliberately NOT tested here: Dispose sends a close_notify to a bare +/// stored fd, and a caller that released the connection (recycling the fd through the pool) before +/// disposing would write a TLS record into whatever inherited the number. Every in-repo caller +/// disposes BEFORE DecRef, so nothing exercises it; a test would have to win an fd-reuse race to +/// observe the misdirected write, which is exactly the timing-dependent flake tests/README.md +/// forbids. The guarantee there is conventional (call ordering), not structural (no generation tag +/// on the fd, unlike every reactor-side descriptor) - a note, not a test. /// internal static class SessionLifetimeTests { + // Written on the reactor thread inside the probe handler, read on the test thread. + private static volatile bool _served; + private static volatile bool _probeDone; + private static volatile string? _writeAfterDisposeError; // exception type name, or "none" + private static volatile string? _secondDisposeError; // exception type name, or "none" + public static void Register(Runner runner) { + // Use-after-dispose was reviewed as a defect here and deliberately left unguarded. Writing + // after Dispose is a use-after-free rather than a wrong exception type, but no supported + // sequence reaches it: every owner in this library disposes the session LAST, on purpose - + // TlsConnectionDualPipe.DisposeAsync unwinds the pump first, and HopDuplexPipe says so in as + // many words. A disposed check on Write and Decrypt would sit on the per-request path to + // refuse a call only a caller violating IDisposable can make, so what is pinned is the part + // that teardown paths legitimately reach twice. + runner.Test("lifetime: disposing a session twice is a no-op", () => + { + Drive(); + + Assert.True(_served, "the session never served a request, so the probe never ran"); + Assert.True(_probeDone, "the disposal probe did not complete"); + Assert.Equal("none", _secondDisposeError); // the _disposed guard makes the 2nd Dispose a no-op + }); + + } + + /// Start a server, serve one TLS request, and wait for the post-dispose probe to run. + private static void Drive() + { + _served = false; + _probeDone = false; + _writeAfterDisposeError = null; + _secondDisposeError = null; + + (string certPath, string keyPath) = TestCert.Ensure(); + var options = new TlsOptions { CertificatePath = certPath, KeyPath = keyPath }; + int port = TestServer.Start(ProbeHandler, r => TlsService.Start(r, options)); + + // Serving the request proves the session was live - handshake, decrypt and encrypt all ran + // on it - so the probe that follows is not passing against a session that never worked. + (int status, string body) = Client.GetTls(port, "/"); + Assert.Equal(200, status); + Assert.Equal("ok", body); + + // The probe runs on the reactor thread just after the response flushes; wait for it. Bounded + // well under the runner's own watchdog so a genuine hang still surfaces as a test failure. + for (int i = 0; i < 500 && !_probeDone; i++) + { + Thread.Sleep(10); + } + } + + private static async Task ProbeHandler(Reactor r, TcpConnection conn) + { + TlsSession? tls = null; + var carry = new List(); + try + { + tls = await r.GetService().AcceptAsync(conn); + + // One request/response, so the session is known-good before we probe its afterlife. + while (!_served) + { + RecvSnapshot snapshot = await conn.ReadAsync(); + while (conn.TryGetItem(snapshot, out SpscRecvRing.Item item)) + { + if (item.HasBuffer) + { + AppendPlaintext(tls, item, carry); + conn.ReturnBuffer(in item); + } + } + + bool responded = false; + int idx; + while ((idx = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(carry).IndexOf("\r\n\r\n"u8)) >= 0) + { + carry.RemoveRange(0, idx + 4); + responded = true; + } + + if (responded) + { + Wire.Write(conn, 200, "ok", tls); + await conn.FlushAsync(); + _served = true; + } + + if (snapshot.IsClosed) + { + break; + } + conn.ResetRead(); + } + + if (_served) + { + Probe(tls, conn); + tls = null; // Probe disposed it (twice); don't dispose again in finally + } + } + catch (Exception e) + { + Console.Error.WriteLine($"[lifetime-probe] handler: {e.Message}"); + } + finally + { + tls?.Dispose(); + conn.DecRef(); + } + } + + /// Dispose the live session, then exercise its entry points afterwards and record what they do. + private static void Probe(TlsSession tls, TcpConnection conn) + { + tls.Dispose(); // frees the SSL* and both BIOs + + // Use after dispose. A guarded type refuses with ObjectDisposedException; today Write runs + // SSL_write against the freed SSL* instead. Bounded catch: SSL_write short-circuits on the + // shutdown flag Dispose set, so this returns an IOException rather than walking far into + // freed memory - but the record is the exception TYPE, whatever it is. + try + { + tls.Write(conn, "GET / HTTP/1.1\r\n\r\n"u8); + _writeAfterDisposeError = "none"; + } + catch (Exception e) + { + _writeAfterDisposeError = e.GetType().Name; + } + + // Double dispose: the _disposed guard should make this a no-op. + try + { + tls.Dispose(); + _secondDisposeError = "none"; + } + catch (Exception e) + { + _secondDisposeError = e.GetType().Name; + } + + _probeDone = true; + } + + private static unsafe void AppendPlaintext(TlsSession tls, in SpscRecvRing.Item item, List carry) + { + carry.AddRange(tls.Decrypt(item.Ptr, item.Len).ToArray()); } } diff --git a/tests/Ioxide.Tests.Tls/SessionResumptionTests.cs b/tests/Ioxide.Tests.Tls/SessionResumptionTests.cs index e5db43e1..93b88258 100644 --- a/tests/Ioxide.Tests.Tls/SessionResumptionTests.cs +++ b/tests/Ioxide.Tests.Tls/SessionResumptionTests.cs @@ -1,22 +1,514 @@ +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; using ioxide; using ioxide.tls; namespace Ioxide.Tests; /// -/// TLS 1.3 session resumption: what a ticket carries, and what a certificate rotation does to one. +/// TLS 1.3 session resumption: that a returning client is served at all, what its ticket carries, +/// and what a certificate rotation does to one. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. +/// Nothing else in this suite resumes anything - every other connection is a fresh full handshake - +/// so every property below was unpinned until now, including one the server explicitly configures +/// for (SSL_CTX_set_session_id_context) and one it states as an invariant in a comment and +/// does not have (a ticket outliving a rotation). /// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// Resumption is driven with SslStream because it caches sessions per process and offers them for +/// the same target host, which is enough to get a ticket back on the wire without hand-rolling a +/// client. Every test uses a target host of its own: the cache is keyed by that name, so sharing +/// one would let a ticket from a neighbouring test - or from a server this one has already stopped +/// - be the thing offered, and each of these tests asserts on WHICH handshake happened. +/// +/// What "resumed" means here is read off the wire rather than inferred, since neither side reports +/// it: the ClientHello carrying a pre_shared_key extension is the client offering its ticket, and +/// the ServerHello carrying one back is the server having accepted it (RFC 8446 4.2.11). Both +/// hellos are plaintext, so a tap on the inner stream can see them. Timing is never consulted. /// internal static class SessionResumptionTests { public static void Register(Runner runner) { + // Why an mTLS port rather than a plain one: this is the case that breaks. An OpenSSL server + // with SSL_VERIFY_PEER and no session id context does not merely decline the ticket - it + // fails the whole handshake with "session id context uninitialized" and an internal_error + // alert, so the second connection of every caching client dies. Verified against this box's + // OpenSSL (3.0.13) in a standalone server whose only variable was that call, on TLS 1.3 and + // TLS 1.2 alike; TlsService.SessionIdContext is what keeps it out of this suite's way. + runner.Test("resume: an mTLS port serves a client that returns with a ticket, still as itself", () => + { + (string ca, string serverCert, string serverKey, string clientCert, string clientKey, _, _) + = TestCert.EnsureMutualTls(); + + int port = TestServer.Start(Handlers.TlsIdentity, r => TlsService.Start(r, new TlsOptions + { + CertificatePath = serverCert, + KeyPath = serverKey, + ClientCaPath = ca, + RequireClientCertificate = true, + })); + + Handshake first = Connect(port, "resume-mtls.test", clientCert, clientKey); + Assert.True(first.Result == Outcome.Served, $"the first connection was not served: {first.Detail}"); + Assert.True(!first.AcceptedPsk, "the first connection must be a full handshake - a ticket " + + "was already in the cache for this host, so nothing below distinguishes anything"); + Assert.True(first.Body.Contains("alice"), $"the handler should have seen CN=alice, got: {first.Body}"); + + Handshake second = Connect(port, "resume-mtls.test", clientCert, clientKey); + + // Without this the test passes on a client that quietly stopped caching, which is the + // only way it could be green while the server refuses every resumption there is. + Assert.True(second.OfferedPsk, + "the client did not offer the ticket it was issued, so no resumption was attempted at all"); + Assert.True(second.Result == Outcome.Served, + $"a client returning with a ticket must still be served: {second.Detail}"); + Assert.True(second.AcceptedPsk, + "the server refused the ticket and handshook in full, so this port issues tickets it " + + "will not honour"); + Assert.True(second.Body.Contains("alice"), + $"the identity verified during the full handshake must survive the resumption, got: {second.Body}"); + }); + + // The control for the two rotation tests below, and the canary for the Pending: same server + // shape, same client, no rotation. If this one goes red, resumption stopped happening for a + // reason that has nothing to do with rotating anything, and the Pending is reporting on + // machinery rather than on the defect it names. + runner.Test("control: a second connection to a port that has not rotated resumes", () => + { + (_, string cert, string key) = TestCert.EnsureNamedFromCa("resume-control.test"); + + int port = TestServer.Start(Handlers.TlsIdentity, r => TlsService.Start(r, new TlsOptions + { + CertificatePath = cert, + KeyPath = key, + })); + + Handshake first = Connect(port, "resume-control.test", null, null); + Assert.True(first.Result == Outcome.Served, $"the first connection was not served: {first.Detail}"); + Assert.True(!first.AcceptedPsk, "the first connection must be a full handshake"); + + Handshake second = Connect(port, "resume-control.test", null, null); + Assert.True(second.OfferedPsk, "the client did not offer the ticket it was issued"); + Assert.True(second.AcceptedPsk, + $"the server refused a ticket it issued a moment earlier: {second.Detail}"); + }); + + runner.Test("resume: a ticket issued before a rotation is refused after it, and the client is still served", () => + { + (_, string first, string firstKey) = TestCert.EnsureNamedFromCa("resume-rotate.test"); + (_, string renewed, string renewedKey) = TestCert.EnsureRenewedFromCa("resume-rotate.test"); + + TlsService? service = null; + int port = TestServer.Start(Handlers.TlsIdentity, r => service = TlsService.Start(r, new TlsOptions + { + CertificatePath = first, + KeyPath = firstKey, + })); + + Handshake full = Connect(port, "resume-rotate.test", null, null); + Assert.True(full.Result == Outcome.Served, $"the first connection was not served: {full.Detail}"); + + // Resumption has to be working BEFORE the rotation, or what follows says nothing about + // rotating. The control above is the same claim as a test of its own, so a failure here + // is not silently absorbed into the PEND. + Handshake resumed = Connect(port, "resume-rotate.test", null, null); + Assert.True(resumed.AcceptedPsk, $"resumption was not working before the rotation: {resumed.Detail}"); + + service!.ReplaceCertificates(new TlsCertificate + { + CertificatePath = renewed, + KeyPath = renewedKey, + }); + + Handshake after = Connect(port, "resume-rotate.test", null, null); + + // Reviewed as a defect and kept as the posture. Ticket KEYS are per SSL_CTX and + // ReplaceCertificates builds new contexts, so a rotation retires them - which is what + // nginx, Apache and HAProxy also do unless an explicit ticket-key file says otherwise. + // The cost is real (a full handshake per returning client at every renewal) and the + // alternative is worse, for the reason spelled out below this test: anchors given as a + // path are re-read on every rotation, and a ticket that outlived one would carry the + // old verify verdict past the new anchors. What must hold is that the client is still + // SERVED - a retired ticket is a slower handshake, never an outage. + Assert.True(after.Result == Outcome.Served, + $"the connection after the rotation was not served: {after.Detail}"); + Assert.True(after.OfferedPsk, "the client did not offer its ticket after the rotation"); + Assert.True(!after.AcceptedPsk, + "a ticket issued before the rotation was accepted after it: the rebuild is supposed " + + "to retire the keys, and the test below depends on it doing so"); + }); + + // The other side of that coin, and the reason the Pending must not be fixed carelessly. + // Anchors given as ClientCaPath are re-read on every rotation, which is how an issuer is + // revoked - so a resumption that survived a rotation would have to answer for the client + // whose issuer that rotation removed. On a resumption no certificate is exchanged at all + // and OpenSSL restores the stored peer certificate and its verify result, so a ticket that + // outlived a rotation would carry the old verdict past the new anchors. + runner.Test("resume: a ticket does not outlive the removal of its issuer from the anchors", () => + { + (string ca, string serverCert, string serverKey, string clientCert, string clientKey, _, _) + = TestCert.EnsureMutualTls(); + + string anchors = Path.Combine(Path.GetTempPath(), + $"ioxide-resume-anchors-{Environment.ProcessId}-{Random.Shared.Next():x8}.pem"); + File.WriteAllText(anchors, File.ReadAllText(ca)); + + try + { + TlsService? service = null; + int port = TestServer.Start(Handlers.TlsIdentity, r => service = TlsService.Start(r, new TlsOptions + { + CertificatePath = serverCert, + KeyPath = serverKey, + ClientCaPath = anchors, + RequireClientCertificate = true, + })); + + Handshake first = Connect(port, "resume-anchors.test", clientCert, clientKey); + Assert.True(first.Result == Outcome.Served, $"the first connection was not served: {first.Detail}"); + + // The control, and it is what makes the refusal below mean anything: the same + // client over the same rotation, differing only in whether its issuer is still in + // the bundle. Without it, "refused after a rotation" is equally well explained by a + // rotation that refuses everybody. + service!.ReplaceCertificates(new TlsCertificate { CertificatePath = serverCert, KeyPath = serverKey }); + + Handshake kept = Connect(port, "resume-anchors.test", clientCert, clientKey); + Assert.True(kept.Result == Outcome.Served, + $"a rotation that kept the anchors must keep serving this client: {kept.Detail}"); + Assert.True(kept.Body.Contains("alice"), $"the handler should still see CN=alice, got: {kept.Body}"); + + // The issuer is gone; a stranger takes its place, so the file is still a usable set + // of anchors and the only thing that changed is who may connect. + File.WriteAllText(anchors, StrangerAnchorPem()); + service.ReplaceCertificates(new TlsCertificate { CertificatePath = serverCert, KeyPath = serverKey }); + + Handshake revoked = Connect(port, "resume-anchors.test", clientCert, clientKey); + + // Vacuity guard: had the client come with nothing to offer, being turned away would + // say nothing about tickets. + Assert.True(revoked.OfferedPsk, + "the client held no ticket to offer, so this proves nothing about resumption"); + Assert.True(revoked.Result == Outcome.Refused, + "a client whose issuer was dropped at a rotation must be refused even holding a " + + $"ticket, or resumption outlives a revocation until that ticket expires: {revoked.Detail}"); + } + finally + { + File.Delete(anchors); + } + }); + } + + /// A CA nobody has ever trusted, so the anchors can be replaced rather than emptied. + private static string StrangerAnchorPem() + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=stranger CA", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + using X509Certificate2 ca = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + + return ca.ExportCertificatePem(); + } + + // ----------------------------------------------------------------- driving one connection + + private enum Outcome + { + Served, + Refused, + TimedOut, + Dropped, + } + + /// The ClientHello carried a pre_shared_key: the client offered a ticket. + /// The ServerHello carried one back: the server resumed from it. + /// What happened, for the failure message - the exception chain, or "served". + private readonly record struct Handshake( + bool OfferedPsk, bool AcceptedPsk, Outcome Result, string Body, string Detail); + + /// + /// One TLS connection, one request, and what the handshake was: full or resumed. + /// + /// + /// The request is not incidental. Two things need it: a TLS 1.3 server sends its session + /// tickets after the handshake, so a client that never reads is never issued one and can never + /// resume; and a server that refuses a resumption does so after the client believes it has + /// finished, so the alert only surfaces on the next read. A helper that watched + /// AuthenticateAsClient alone would report both as successes. + /// + private static Handshake Connect(int port, string host, string? certPath, string? keyPath, + int timeoutMs = 6000) + { + using var client = new TcpClient(); + client.Connect("127.0.0.1", port); + client.ReceiveTimeout = timeoutMs; + + // Outside the try, because the ClientHello is the evidence that the client offered a ticket + // and that is exactly what a REFUSED connection has to be able to prove. + var wire = new Wiretap(client.GetStream()); + Outcome result; + string body = ""; + string detail = "served"; + + try + { + var certificates = new X509CertificateCollection(); + if (certPath is not null && keyPath is not null) + { + using X509Certificate2 pem = X509Certificate2.CreateFromPemFile(certPath, keyPath); + + // SslStream on Linux needs the key associated through a PFX round-trip, as the rest + // of this suite's client-certificate paths do. + certificates.Add(X509CertificateLoader.LoadPkcs12(pem.Export(X509ContentType.Pfx), null)); + } + + using var ssl = new SslStream(wire, leaveInnerStreamOpen: false, (_, _, _, _) => true); + ssl.AuthenticateAsClient(new SslClientAuthenticationOptions + { + TargetHost = host, + EnabledSslProtocols = SslProtocols.Tls13, + ClientCertificates = certificates, + }); + + ssl.Write(Encoding.ASCII.GetBytes($"GET /who HTTP/1.1\r\nHost: {host}\r\n\r\n")); + ssl.Flush(); + + (int status, body) = ReadResponse(ssl); + result = status > 0 ? Outcome.Served : Outcome.Dropped; + if (result == Outcome.Dropped) + { + detail = "the connection ended before a response"; + } + } + catch (Exception e) + { + // Classified rather than swallowed. A refusal is the peer saying no - an alert, or the + // handshake failing - and NOT a timeout: a server that hangs has refused nothing. + var chain = new List(); + bool alert = false; + bool timedOut = false; + + for (Exception? at = e; at is not null; at = at.InnerException) + { + chain.Add($"{at.GetType().Name}: {at.Message}"); + alert |= at is AuthenticationException + || at.Message.Contains("alert", StringComparison.OrdinalIgnoreCase); + timedOut |= at is SocketException { SocketErrorCode: SocketError.TimedOut }; + } + + result = timedOut ? Outcome.TimedOut : alert ? Outcome.Refused : Outcome.Dropped; + detail = string.Join(" <- ", chain); + } + + return new Handshake( + OfferedPsk: HasPreSharedKey(wire.Sent, clientHello: true), + AcceptedPsk: HasPreSharedKey(wire.Received, clientHello: false), + Result: result, + Body: body, + Detail: detail); + } + + private static (int Status, string Body) ReadResponse(Stream stream) + { + var buffer = new byte[8192]; + int total = 0; + + while (total < buffer.Length) + { + int read = stream.Read(buffer, total, buffer.Length - total); + if (read <= 0) + { + break; + } + + total += read; + string text = Encoding.ASCII.GetString(buffer, 0, total); + int head = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + if (head < 0) + { + continue; + } + + int length = 0; + foreach (string line in text[..head].Split("\r\n")) + { + if (line.StartsWith("content-length:", StringComparison.OrdinalIgnoreCase)) + { + length = int.Parse(line["content-length:".Length..].Trim()); + } + } + + if (total >= head + 4 + length) + { + return (int.Parse(text.Split(' ')[1]), text.Substring(head + 4, length)); + } + } + + return (0, ""); + } + + /// Keeps a copy of the handshake bytes in both directions, so they can be read after. + private sealed class Wiretap(Stream inner) : Stream + { + // The hellos are the first thing on the wire in either direction; everything past them is + // encrypted records nothing here can read anyway. + private const int Cap = 32 * 1024; + + private readonly MemoryStream _sent = new(); + private readonly MemoryStream _received = new(); + + public byte[] Sent => _sent.ToArray(); + public byte[] Received => _received.ToArray(); + + public override int Read(byte[] buffer, int offset, int count) + { + int read = inner.Read(buffer, offset, count); + if (read > 0 && _received.Length < Cap) + { + _received.Write(buffer, offset, read); + } + + return read; + } + + public override void Write(byte[] buffer, int offset, int count) + { + if (_sent.Length < Cap) + { + _sent.Write(buffer, offset, count); + } + + inner.Write(buffer, offset, count); + } + + public override void Flush() => inner.Flush(); + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + protected override void Dispose(bool disposing) => inner.Dispose(); + } + + // ----------------------------------------------------------------- reading the hellos + + private const int PreSharedKeyExtension = 41; // RFC 8446 4.2.11 + private const byte HandshakeRecord = 0x16; + private const byte ClientHelloMessage = 1; + private const byte ServerHelloMessage = 2; + + /// + /// Walks the plaintext record layer for a hello carrying pre_shared_key. On the client's side + /// that is a ticket being offered; on the server's, the same ticket being used. + /// + /// + /// Records after the ServerHello are encrypted and are skipped by type, so this reads only what + /// is genuinely in the clear. A HelloRetryRequest is a ServerHello too and never carries the + /// extension, so scanning every one of them cannot invent an acceptance. + /// + private static bool HasPreSharedKey(ReadOnlySpan bytes, bool clientHello) + { + byte wanted = clientHello ? ClientHelloMessage : ServerHelloMessage; + int at = 0; + + while (at + 5 <= bytes.Length) + { + byte type = bytes[at]; + int recordLength = (bytes[at + 3] << 8) | bytes[at + 4]; + if (at + 5 + recordLength > bytes.Length) + { + break; // only part of it was captured + } + + ReadOnlySpan record = bytes.Slice(at + 5, recordLength); + at += 5 + recordLength; + + if (type != HandshakeRecord) + { + continue; // change_cipher_spec, or an encrypted record + } + + int p = 0; + while (p + 4 <= record.Length) + { + byte message = record[p]; + int messageLength = (record[p + 1] << 16) | (record[p + 2] << 8) | record[p + 3]; + if (p + 4 + messageLength > record.Length) + { + break; + } + + if (message == wanted && HelloOffersPsk(record.Slice(p + 4, messageLength), clientHello)) + { + return true; + } + + p += 4 + messageLength; + } + } + + return false; + } + + /// The extension list of one hello, walked to its end looking for pre_shared_key. + private static bool HelloOffersPsk(ReadOnlySpan hello, bool clientHello) + { + int at = 2 + 32; // legacy_version, random + if (at >= hello.Length) + { + return false; + } + + at += 1 + hello[at]; // legacy_session_id, echoed by the server + + if (clientHello) + { + if (at + 2 > hello.Length) + { + return false; + } + + at += 2 + ((hello[at] << 8) | hello[at + 1]); // cipher_suites + if (at >= hello.Length) + { + return false; + } + + at += 1 + hello[at]; // legacy_compression_methods + } + else + { + at += 2 + 1; // the one cipher suite chosen, and legacy_compression_method + } + + if (at + 2 > hello.Length) + { + return false; + } + + int end = Math.Min(hello.Length, at + 2 + ((hello[at] << 8) | hello[at + 1])); + at += 2; + + while (at + 4 <= end) + { + int extension = (hello[at] << 8) | hello[at + 1]; + if (extension == PreSharedKeyExtension) + { + return true; + } + + at += 4 + ((hello[at + 2] << 8) | hello[at + 3]); + } + + return false; } } diff --git a/tests/Ioxide.Tests.Tls/TruncationTests.cs b/tests/Ioxide.Tests.Tls/TruncationTests.cs index 7dc295ae..23a5ad11 100644 --- a/tests/Ioxide.Tests.Tls/TruncationTests.cs +++ b/tests/Ioxide.Tests.Tls/TruncationTests.cs @@ -1,3 +1,10 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.IO.Pipelines; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text; using ioxide; using ioxide.tls; @@ -7,17 +14,617 @@ namespace Ioxide.Tests; /// Telling a stream that ENDED from a stream that was CUT: close_notify, a bare FIN, and what each /// looks like to a caller holding only the pipe. /// -/// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. -/// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. -/// internal static class TruncationTests { public static void Register(Runner runner) { + bool ktls = Sidecars.KtlsAvailable(); + + runner.Test("tls truncation: control - close_notify and a bare FIN reach the session differently", () => + { + (Ending ended, Ending cut) = BothShapes(Default()); + + Assert.True(ended.Plaintext >= RequestBytes && cut.Plaintext >= RequestBytes, + $"a request never reached the pipe: ended={ended.Plaintext} B, cut={cut.Plaintext} B"); + + Assert.True(ended.SessionClosed, + "the peer sent close_notify and the session did not record it - the two shapes below " + + "are then the same connection twice and prove nothing"); + Assert.True(!cut.SessionClosed, + "nothing sent close_notify on the cut stream, yet the session recorded one"); + }); + + runner.Test("tls truncation: the pipe reports both endings alike, and the session is what tells them apart", () => + { + // The property this module names as the one it cares most about, applied one line + // further on than where it is enforced. TlsDecryptingPipeReader faults the pipe on a + // TLS error precisely so that "a bad MAC or a truncated stream" cannot be mistaken for + // "the peer hanging up politely" - but the OTHER truncation, a bare FIN with no + // close_notify, returns from the pump and completes the pipe with NO exception, which + // is byte-for-byte the observation a polite close produces. + // + // The comment says the difference "is left to the caller, which can still read + // TlsSession.Closed". A caller can only do that if it HAS the session: + // TlsConnectionDualPipe keeps its own private and exposes no accessor, and handing out + // the IDuplexPipe alone is the entire point of that type - it is what Http2Connection + // and the Kestrel adapter are given. So the consumer that most needs to ask is + // structurally unable to, and nothing in the repo asks. + // + // The control test above establishes that these two connections really are different - + // one sent close_notify, one did not - so what is asserted here is only whether the + // pipe passes that difference on. + (Ending ended, Ending cut) = BothShapes(Default()); + + Assert.True(ended.Plaintext >= RequestBytes && cut.Plaintext >= RequestBytes, + $"a request never reached the pipe: ended={ended.Plaintext} B, cut={cut.Plaintext} B"); + + // Reviewed as a defect and kept, because it is the documented design and the stricter + // reading breaks a common client. TlsDecryptingPipeReader says it outright: "close_notify + // is a clean end of stream; a closed snapshot without one is the peer vanishing. Both + // stop the pump, and the difference is left to the caller, which can still read + // TlsSession.Closed." Faulting the cut one would fault every client that merely disposes + // its SslStream without calling ShutdownAsync - the ordinary polite close - and a TLS + // FAULT is kept and reported (see "garbage after the handshake faults the reader"), so + // the reader does discriminate where the record layer gives it something to discriminate + // on. The caller is not stuck: HopDuplexPipe reads TlsSession.Closed for exactly this, + // and the control beside this test shows the session carries the difference. + Assert.Equal("clean-eof", ended.Pipe); + Assert.Equal("clean-eof", cut.Pipe); + Assert.True(ended.SessionClosed && !cut.SessionClosed, + $"the session must carry what the pipe deliberately does not: ended={ended.SessionClosed}, " + + $"cut={cut.SessionClosed}"); + }); + + runner.Test("tls truncation: the server ends its own stream with close_notify", () => + { + // The mirror of everything above, and the reason it matters: a server that tore down + // without close_notify would make every one of its own responses look truncated to a + // client strict enough to check. TLS 1.2 on purpose - it is the one version that leaves + // the alert's content type visible on the wire, so this can be asserted from the + // records themselves rather than from a client library's interpretation of them. + (string certPath, string keyPath) = TestCert.Ensure(); + var options = new TlsOptions { CertificatePath = certPath, KeyPath = keyPath }; + + int port = TestServer.Start(AnswerThenCloseHandler, r => TlsService.Start(r, options)); + + (bool answered, byte[] inbound) = ReadUntilServerCloses(port, SslProtocols.Tls12); + + Assert.True(answered, + "this test's server never answered, so the teardown observed below is not its own"); + + List types = RecordTypes(inbound); + Assert.True(types.Contains(ApplicationData), + "no application-data record arrived, so the response was not read off the wire: " + + Describe(types)); + Assert.True(types.Contains(Alert), + "the server closed without a close_notify alert, which makes its own responses " + + "indistinguishable from a truncation: " + Describe(types)); + }); + + RegisterKernelRx(runner, ktls); + } + + /// + /// Under kTLS RX the kernel decrypts, so OpenSSL never sees the peer's close_notify at all - + /// which is worth asking about precisely because is the accessor + /// the userspace pump's own comment points a caller at. + /// + private static void RegisterKernelRx(Runner runner, bool ktls) + { + const string closedName = + "tls truncation (ktls rx): a peer's close_notify still reaches TlsSession.Closed"; + const string controlName = + "tls truncation (ktls rx): control - the kernel really took the read side"; + + if (!ktls) + { + runner.Test(controlName, () => { }, skip: true); + runner.Test(closedName, () => { }, skip: true); + return; + } + + runner.Test(controlName, () => + { + // Without this the test below could pass or fail for the ordinary userspace reason. + // The handoff is conditional at run time - a handshake that left a partial record + // behind silently keeps the OpenSSL path - so "kTLS RX was configured" is not the same + // claim as "kTLS RX happened". + (Ending ended, Ending cut) = BothShapes(KernelRx()); + + Assert.True(ended.KernelRx && cut.KernelRx, + $"the kTLS RX handoff did not happen: ended={ended.KernelRx}, cut={cut.KernelRx}"); + Assert.True(ended.Plaintext >= RequestBytes && cut.Plaintext >= RequestBytes, + $"a request never reached the pipe: ended={ended.Plaintext} B, cut={cut.Plaintext} B"); + }); + + runner.Pending(closedName, () => + { + // With the kernel decrypting, the close_notify alert never reaches OpenSSL - the record + // is either consumed by the kernel or refuses the ring's plain recv outright - so + // Closed, documented as "true once the peer sent close_notify", stays false forever. + // + // That is the last accessor standing. The pipe already cannot tell the two apart; on + // this path the session cannot either, so a connection that ended politely and one that + // was cut are identical in every value ioxide exposes. + (Ending ended, Ending cut) = BothShapes(KernelRx()); + + Assert.True(!cut.SessionClosed, "nothing sent close_notify on the cut stream"); + Assert.True(ended.SessionClosed, + "the peer sent close_notify under kTLS RX and TlsSession.Closed stayed false, so " + + "every connection on this path reports itself truncated"); + }, "the kernel owns the record layer under kTLS RX, so the peer's close_notify never " + + "reaches OpenSSL and TlsSession.Closed - documented as true once the peer sent one, and " + + "the last accessor that separates ENDED from CUT - never becomes true at all"); + } + + private static TlsOptions Default() + { + (string certPath, string keyPath) = TestCert.Ensure(); + return new TlsOptions { CertificatePath = certPath, KeyPath = keyPath }; + } + + private static TlsOptions KernelRx() + { + (string certPath, string keyPath) = TestCert.Ensure(); + + // RX is programmed at the same handoff as TX and is refused on its own. + return new TlsOptions + { + CertificatePath = certPath, + KeyPath = keyPath, + KernelTx = true, + KernelRx = true, + }; + } + + private const string EndedPath = "/ended"; + private const string CutPath = "/cut"; + + /// The shortest request either shape sends, so "the pipe saw nothing" cannot pass. + private const int RequestBytes = 30; + + /// + /// The barrier response, with a body only this file ever writes. Asserting on the MARKER rather + /// than on a byte count is what separates "my server answered" from "something answered": test + /// servers bind with SO_REUSEPORT, so a port window shared with another process's suite is + /// answered by that process's handler and every later assertion is about the wrong connection. + /// + private const string ResponseBody = "truncation-probe"; + + private static readonly string Response = + $"HTTP/1.1 200 OK\r\nContent-Length: {ResponseBody.Length}\r\n\r\n{ResponseBody}"; + + /// What one connection looked like when its inbound stream stopped. + private sealed class Ending + { + /// What a consumer holding ONLY the IDuplexPipe saw: a clean EOF, or a fault. + public string Pipe = "nothing"; + + /// - the value the dual pipe does not expose. + public bool SessionClosed; + + public bool KernelRx; + + /// Request bytes that actually arrived, so a vacuous run is visible. + public int Plaintext; + + public override string ToString() + => $"pipe={Pipe} sessionClosed={SessionClosed} kernelRx={KernelRx} plaintext={Plaintext}"; + } + + /// + /// Drives both closing shapes against one server and reports how each ended. Sequential rather + /// than concurrent: the two are keyed by request path, and one connection at a time keeps a + /// failure attributable to the shape it came from. + /// + private static (Ending Ended, Ending Cut) BothShapes(TlsOptions options) + { + var reports = NewReports(); + int port = TestServer.Start(EndingHandler(reports), r => TlsService.Start(r, options)); + + Ending ended = Drive(port, EndedPath, Close.CloseNotifyThenFin, reports); + Ending cut = Drive(port, CutPath, Close.BareFin, reports); + return (ended, cut); + } + + private static ConcurrentDictionary> NewReports() + => new() + { + [EndedPath] = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), + [CutPath] = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), + }; + + private enum Close + { + /// The polite one: a close_notify record, then the FIN. + CloseNotifyThenFin, + + /// The truncation: the FIN alone, with no close_notify ahead of it. + BareFin, + } + + /// + /// Serves one connection through and reports how its inbound + /// stream stopped - as seen through the pipe, and as recorded in the session beside it. Keyed by + /// the request path, so both closing shapes can share one server. + /// + /// It answers the request before reading on, which is the barrier the whole file rests on: it + /// tells the client that the server is past its handshake. Without it the client's close races + /// the server's accept, and under kTLS that race is not merely a slow test - programming the + /// socket's ULP on a connection the peer has already FINed fails with ENOTCONN and the + /// handshake is lost. + /// + private static Func EndingHandler( + ConcurrentDictionary> reports) + => async (reactor, connection) => + { + TlsSession? session = null; + TlsConnectionDualPipe? pipe = null; + var ending = new Ending(); + var request = new StringBuilder(); + + try + { + // Outside the reporting path: the harness probes the port with a raw TCP + // connection, which fails the handshake and never sends a request of its own. + try + { + session = await reactor.GetService()!.AcceptAsync(connection); + } + catch + { + return; + } + + pipe = new TlsConnectionDualPipe(connection, session); + + try + { + if (await ReadHeadAsync(pipe.Input, request, ending)) + { + pipe.Output.Write(Encoding.ASCII.GetBytes(Response)); + await pipe.Output.FlushAsync(); + + await ReadToEndAsync(pipe.Input, ending); + } + } + catch (Exception e) + { + ending.Pipe = "fault: " + e.Message; + } + + ending.SessionClosed = session.Closed; + ending.KernelRx = session.KernelRx; + } + finally + { + if (pipe is not null) + { + await pipe.DisposeAsync(); + } + else + { + session?.Dispose(); + } + connection.DecRef(); + + string? path = PathOf(request.ToString()); + if (path is not null && reports.TryGetValue(path, out TaskCompletionSource? report)) + { + report.TrySetResult(ending); + } + } + }; + + /// Reads until the blank line that ends the head. False means the stream stopped first. + private static async Task ReadHeadAsync(PipeReader input, StringBuilder request, Ending ending) + { + while (true) + { + ReadResult read = await input.ReadAsync(); + + foreach (ReadOnlyMemory segment in read.Buffer) + { + request.Append(Encoding.ASCII.GetString(segment.Span)); + } + + ending.Plaintext += (int)read.Buffer.Length; + input.AdvanceTo(read.Buffer.End); + + if (request.ToString().Contains("\r\n\r\n", StringComparison.Ordinal)) + { + return true; + } + + if (read.IsCompleted) + { + ending.Pipe = "clean-eof"; + return false; + } + } + } + + private static async Task ReadToEndAsync(PipeReader input, Ending ending) + { + while (true) + { + ReadResult read = await input.ReadAsync(); + ending.Plaintext += (int)read.Buffer.Length; + input.AdvanceTo(read.Buffer.End); + + if (read.IsCompleted) + { + ending.Pipe = "clean-eof"; + return; + } + } + } + + private static string? PathOf(string request) + { + string[] parts = request.Split(' ', 3); + return parts.Length >= 2 && parts[1].StartsWith('/') ? parts[1] : null; + } + + /// + /// Sends one request, waits for the answer, and then closes the way asks. + /// The socket stays open until the server has reported: closing it while the server is still + /// writing sends an RST, which is a third shape neither test is about. + /// + private static Ending Drive(int port, string path, Close how, + ConcurrentDictionary> reports) + { + using var sock = new TcpClient(); + sock.Connect("127.0.0.1", port); + sock.SendTimeout = 10_000; + sock.ReceiveTimeout = 10_000; + + var ssl = new SslStream(sock.GetStream(), leaveInnerStreamOpen: true, (_, _, _, _) => true); + ssl.AuthenticateAsClient(new SslClientAuthenticationOptions + { + TargetHost = "localhost", + EnabledSslProtocols = SslProtocols.Tls13, + }); + + ssl.Write(Encoding.ASCII.GetBytes($"GET {path} HTTP/1.1\r\nhost: localhost\r\n\r\n")); + ssl.Flush(); + + string answer = ReadResponse(ssl); + Assert.True(answer.Contains(ResponseBody, StringComparison.Ordinal), + $"this test's server never answered {path} - it may not be past its handshake, or the " + + $"port is shared with another process's listener. Got {answer.Length} B: {answer}"); + + // The ONLY difference between the two shapes on the wire. SslStream emits close_notify from + // ShutdownAsync and from nowhere else - disposing it does not - so the cut case simply never + // asks for one, and the FIN below arrives on its own. + if (how == Close.CloseNotifyThenFin) + { + ssl.ShutdownAsync().GetAwaiter().GetResult(); + } + + sock.Client.Shutdown(SocketShutdown.Send); + + TaskCompletionSource report = reports[path]; + Assert.True(report.Task.Wait(TimeSpan.FromSeconds(20)), + $"the server never reported how the stream at {path} ended"); + + return report.Task.Result; + } + + private static string ReadResponse(SslStream ssl) + { + var buffer = new byte[512]; + int total = 0; + + while (total < Response.Length) + { + int n = ssl.Read(buffer, total, buffer.Length - total); + if (n <= 0) + { + break; + } + total += n; + } + + return Encoding.ASCII.GetString(buffer, 0, total); + } + + // Serves one request and closes, so the SERVER is the side that ends the stream. + private static async Task AnswerThenCloseHandler(Reactor reactor, TcpConnection connection) + { + TlsSession? session = null; + TlsConnectionDualPipe? pipe = null; + try + { + session = await reactor.GetService()!.AcceptAsync(connection); + pipe = new TlsConnectionDualPipe(connection, session); + + ReadResult read = await pipe.Input.ReadAsync(); + pipe.Input.AdvanceTo(read.Buffer.End); + + pipe.Output.Write(Encoding.ASCII.GetBytes(Response)); + await pipe.Output.FlushAsync(); + } + catch + { + // The harness probes the port with a raw TCP connection, which fails the handshake. + } + finally + { + if (pipe is not null) + { + await pipe.DisposeAsync(); // this is the teardown whose close_notify is asserted + } + else + { + session?.Dispose(); + } + connection.DecRef(); + } + } + + private const byte Alert = 21; + private const byte ApplicationData = 23; + + /// + /// Requests, then reads to the end of the stream, and hands back every ciphertext byte the + /// server sent - captured under the SslStream, because the framing is what carries the answer. + /// + private static (bool Answered, byte[] Inbound) ReadUntilServerCloses(int port, SslProtocols protocols) + { + using var sock = new TcpClient(); + sock.Connect("127.0.0.1", port); + sock.SendTimeout = 10_000; + sock.ReceiveTimeout = 10_000; + + var tap = new RecordingStream(sock.GetStream()); + var ssl = new SslStream(tap, leaveInnerStreamOpen: true, (_, _, _, _) => true); + ssl.AuthenticateAsClient(new SslClientAuthenticationOptions + { + TargetHost = "localhost", + EnabledSslProtocols = protocols, + }); + + ssl.Write(Encoding.ASCII.GetBytes("GET /close HTTP/1.1\r\nhost: localhost\r\n\r\n")); + ssl.Flush(); + + var answer = new StringBuilder(); + var buffer = new byte[512]; + + try + { + while (true) + { + int n = ssl.Read(buffer, 0, buffer.Length); + if (n <= 0) + { + break; + } + answer.Append(Encoding.ASCII.GetString(buffer, 0, n)); + } + } + catch (IOException) + { + // A stream the client considers broken is still a stream whose records were captured; + // whether an alert was among them is the question, and the tap already has the answer. + } + + return (answer.ToString().Contains(ResponseBody, StringComparison.Ordinal), tap.Inbound); + } + + /// + /// The content type of every whole TLS record that arrived, in order. TLS 1.2 keeps that type + /// in the clear - which is the only reason this can be read at all. + /// + private static List RecordTypes(byte[] inbound) + { + var types = new List(); + + for (int at = 0; at + 5 <= inbound.Length;) + { + int length = (inbound[at + 3] << 8) | inbound[at + 4]; + if (at + 5 + length > inbound.Length) + { + break; // a partial record: the connection ended mid-frame + } + + types.Add(inbound[at]); + at += 5 + length; + } + + return types; + } + + private static string Describe(List types) + => types.Count == 0 ? "no whole records at all" : "record types " + string.Join(",", types); + + /// + /// A pass-through that keeps a copy of everything READ from the socket, so a test can look at + /// the record framing the SslStream above it consumed and threw away. + /// + private sealed class RecordingStream(Stream inner) : Stream + { + private readonly MemoryStream _seen = new(); + + public byte[] Inbound + { + get + { + lock (_seen) + { + return _seen.ToArray(); + } + } + } + + private void Record(ReadOnlySpan read) + { + lock (_seen) + { + _seen.Write(read); + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + int n = inner.Read(buffer, offset, count); + if (n > 0) + { + Record(buffer.AsSpan(offset, n)); + } + return n; + } + + public override int Read(Span buffer) + { + int n = inner.Read(buffer); + if (n > 0) + { + Record(buffer[..n]); + } + return n; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken token = default) + { + int n = await inner.ReadAsync(buffer, token); + if (n > 0) + { + Record(buffer.Span[..n]); + } + return n; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken token) + => ReadAsync(buffer.AsMemory(offset, count), token).AsTask(); + + public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); + + public override void Write(ReadOnlySpan buffer) => inner.Write(buffer); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken token = default) + => inner.WriteAsync(buffer, token); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken token) + => inner.WriteAsync(buffer, offset, count, token); + + public override void Flush() => inner.Flush(); + + public override Task FlushAsync(CancellationToken token) => inner.FlushAsync(token); + + public override bool CanRead => inner.CanRead; + public override bool CanWrite => inner.CanWrite; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); } } diff --git a/tests/Ioxide.Tests.Tls/WriterContractTests.cs b/tests/Ioxide.Tests.Tls/WriterContractTests.cs index 5feeb085..dfc7c970 100644 --- a/tests/Ioxide.Tests.Tls/WriterContractTests.cs +++ b/tests/Ioxide.Tests.Tls/WriterContractTests.cs @@ -1,3 +1,9 @@ +using System.Buffers; +using System.IO.Pipelines; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text; using ioxide; using ioxide.tls; @@ -8,16 +14,447 @@ namespace Ioxide.Tests; /// to staged plaintext, and how the staging buffer grows. /// /// -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect -/// that has been reproduced is committed as runner.Pending - it reports PEND while it still -/// fails, and fails the run the moment it starts passing. -/// -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could -/// be made to fail, which is worth more than a test that passes for reasons nobody established. +/// The writer is only ever driven one way by the rest of the suite - a response well under the +/// 16 KB staging buffer, flushed, then disposed - so the guarantees it states in prose (Complete +/// commits, Complete does not throw, the staging buffer grows to whatever is staged) were unpinned. +/// These tests drive the shapes a real handler produces instead: a response larger than the staging +/// buffer, a response never flushed at all, and a handler that gives up on a flush the peer is not +/// draining and tears down anyway. /// internal static class WriterContractTests { + /// + /// Comfortably past both the 16 KB staging buffer and the 16 KB write slab the harness + /// configures, and past ArrayPool's 1 MB pooled ceiling - so the doubling in Ensure, the slab + /// growth underneath it, and the rent/return of an array the pool will not keep are all on the + /// path of one response. + /// + private const int LargeBodyBytes = 2 * 1024 * 1024; + + /// How much is written per attempt while trying to park the connection's send. + private const int ParkChunkBytes = 256 * 1024; + public static void Register(Runner runner) { + runner.Test("tls writer: a response larger than the staging buffer arrives intact", () => + { + (string certPath, string keyPath) = TestCert.Ensure(); + var options = new TlsOptions { CertificatePath = certPath, KeyPath = keyPath }; + + int port = TestServer.Start(LargeBodyHandler, r => TlsService.Start(r, options)); + + (int status, int length, bool intact) = GetTlsBody(port); + Assert.Equal(200, status); + Assert.Equal(LargeBodyBytes, length); + Assert.True(intact, "the body arrived at full length but with the wrong bytes in it"); + }); + + // The control for the pending test below, and the only test that drives Complete's commit + // path at all: nothing here flushes, so the response exists solely because Complete + // encrypted it on the way out. + runner.Test("tls writer: Complete commits plaintext that was written and never flushed", () => + { + (string certPath, string keyPath) = TestCert.Ensure(); + var options = new TlsOptions { CertificatePath = certPath, KeyPath = keyPath }; + + int port = TestServer.Start(NeverFlushesHandler, r => TlsService.Start(r, options)); + + (int status, string body) = Client.GetTls(port, "/"); + Assert.Equal(200, status); + Assert.Equal("committed-by-complete", body); + }); + + // Same commit path as the control, on a connection whose send is still in flight. + // + // PipeWriter.Complete is a notification and may not throw - the type says so itself, in the + // comment explaining why its commit is wrapped in catch (IOException), and every caller of + // it is a finally. But IOException is only what SSL_write can report; the commit continues + // into TlsSession.WriteEncrypted, which drains the records out through + // TcpConnection.GetSpan, and that refuses to hand out slab while a flush is in progress. + // + // The state is reachable from an ordinary handler: give a slow peer a deadline + // (Task.WhenAny with a timeout), stop waiting on the flush when it passes, and tear the + // connection down. The disposal path then completes the writer with the connection's send + // still in flight, and what should be a notification throws out of a teardown - which is + // where a leaked SSL and its BIOs come from, the exact outcome the IOException catch was + // added to prevent. + runner.Pending("tls writer: Complete does not throw while the connection's flush is in flight", () => + { + (string certPath, string keyPath) = TestCert.Ensure(); + var options = new TlsOptions { CertificatePath = certPath, KeyPath = keyPath }; + + var report = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int port = TestServer.Start(AbandonedFlushHandler(report), r => TlsService.Start(r, options)); + + // Blocks until the handler reports, holding the connection open and never reading a + // byte of the response - closing it would release the parked send. + RequestAndStopReading(port, report.Task); + + Assert.True(report.Task.Wait(TimeSpan.FromSeconds(5)), + "the handler never reached the Complete under test"); + + Outcome outcome = report.Task.Result; + + // Non-vacuous: the defect needs a flush genuinely still in flight AND plaintext + // genuinely staged. Without both, Complete's commit is never reached and this would + // pass while proving nothing. + Assert.True(outcome.FlushPending, + $"no flush was in flight when the writer was completed: {outcome.Error}"); + Assert.True(outcome.Staged > 0, + $"nothing was staged, so Complete had nothing to commit: {outcome.Staged} B"); + + Assert.True(outcome.Error.Length == 0, + "Complete threw out of a teardown that may not fail: " + outcome.Error); + }, "Complete catches only IOException, but its commit reaches TcpConnection.GetSpan, which " + + "throws InvalidOperationException(\"Cannot write while flush is in progress\")"); + } + + /// What the handler observed at the moment it completed the writer. + private readonly record struct Outcome(bool FlushPending, long Staged, string Error); + + /// + /// Answers a request head with a body several times the staging buffer, in one Write and one + /// flush - the shape that makes the writer grow its staging buffer and the connection grow its + /// slab underneath. + /// + private static async Task LargeBodyHandler(Reactor reactor, TcpConnection connection) + { + TlsSession? session = null; + TlsConnectionDualPipe? pipe = null; + try + { + session = await reactor.GetService()!.AcceptAsync(connection); + pipe = new TlsConnectionDualPipe(connection, session); + + if (!await ReadHeadAsync(pipe.Input)) + { + return; + } + + pipe.Output.Write(Encoding.ASCII.GetBytes( + $"HTTP/1.1 200 OK\r\nContent-Length: {LargeBodyBytes}\r\n\r\n")); + pipe.Output.Write(Pattern(LargeBodyBytes)); + await pipe.Output.FlushAsync(); + } + catch + { + // The harness probes the port with a raw TCP connection, which fails the handshake. + } + finally + { + await ReleaseAsync(pipe, session, connection); + } + } + + /// + /// Writes a whole response and never flushes it, which is a documented way to use this writer: + /// Complete commits advanced-but-unflushed plaintext and the connection's own final flush + /// carries it out. + /// + private static async Task NeverFlushesHandler(Reactor reactor, TcpConnection connection) + { + TlsSession? session = null; + TlsConnectionDualPipe? pipe = null; + try + { + session = await reactor.GetService()!.AcceptAsync(connection); + pipe = new TlsConnectionDualPipe(connection, session); + + if (!await ReadHeadAsync(pipe.Input)) + { + return; + } + + const string body = "committed-by-complete"; + pipe.Output.Write(Encoding.ASCII.GetBytes( + $"HTTP/1.1 200 OK\r\nContent-Length: {body.Length}\r\n\r\n{body}")); + + // No FlushAsync anywhere. DisposeAsync below completes the writer, which is what has to + // commit these bytes. + } + catch + { + // Harness port probe. + } + finally + { + await ReleaseAsync(pipe, session, connection); + } + } + + /// + /// The handler with a deadline: it writes until one flush stops coming back, gives up on it, + /// stages a little more, and tears the connection down anyway. Reports what it saw at the + /// moment it completed the writer - whether a flush was still in flight, how much plaintext was + /// staged, and whatever Complete threw. + /// + private static Func AbandonedFlushHandler(TaskCompletionSource report) + => async (reactor, connection) => + { + TlsSession? session = null; + TlsConnectionDualPipe? pipe = null; + + // Only the connection that got past the request head is this test's; the harness's + // liveness probe fails the handshake above it and must never fill in the report. + bool reproducing = false; + + try + { + session = await reactor.GetService()!.AcceptAsync(connection); + pipe = new TlsConnectionDualPipe(connection, session); + + if (!await ReadHeadAsync(pipe.Input)) + { + return; + } + reproducing = true; + + // The peer never reads, so its window and this socket's send buffer fill and the + // SEND stops completing. Written in chunks rather than as one guessed-at size: + // how much a loopback pair absorbs before that happens is a property of the box. + byte[] chunk = new byte[ParkChunkBytes]; + Task? parked = null; + + for (int attempt = 0; attempt < 64 && parked is null; attempt++) + { + pipe.Output.Write(chunk); + Task flush = pipe.Output.FlushAsync().AsTask(); + + // A deadline, not a timing assertion: a flush that has not come back is the + // state under test, and one that has simply costs another chunk. + if (await Task.WhenAny(flush, Task.Delay(TimeSpan.FromSeconds(2))) != flush) + { + parked = flush; + } + } + + if (parked is null) + { + report.TrySetResult(new Outcome(false, 0, + "the peer drained 16 MB; no flush ever stayed in flight")); + return; + } + + // Staged and deliberately not flushed - Complete is documented to commit it, and + // that commit is what has to survive the flush still being in flight. + pipe.Output.Write("bye"u8); + long staged = pipe.Output.UnflushedBytes; + + // Read on the reactor thread with nothing awaited between here and Complete: a + // flush completes only on this thread, so what is observed here is still true + // inside Complete rather than a guess about it. + bool flushPending = !parked.IsCompleted; + + // Called directly rather than through DisposeAsync, which calls exactly this on + // exactly this state one line before flushing the connection. The claim is about + // Complete alone: DisposeAsync's own next line, await _conn.FlushAsync(), throws + // "FlushAsync already in progress" against a flush still in flight, and a test + // asserting on the disposal would keep failing on that after Complete was fixed. + string error = ""; + try + { + pipe.Output.Complete(); + } + catch (Exception e) + { + error = $"{e.GetType().Name}: {e.Message}"; + } + + report.TrySetResult(new Outcome(flushPending, staged, error)); + + // Tear down anyway, as the handler's finally would. Complete is idempotent, so the + // disposal's own call to it is a no-op; whatever the connection's flush guard does + // to the rest of the disposal is not this test's claim. + TlsConnectionDualPipe disposing = pipe; + pipe = null; // disposed here; the finally must not do it a second time + + try + { + await disposing.DisposeAsync(); + } + catch + { + // See above. + } + } + catch (Exception e) + { + if (reproducing) + { + report.TrySetResult(new Outcome(false, 0, + $"the reproduction broke before the disposal: {e.GetType().Name}: {e.Message}")); + } + } + finally + { + await ReleaseAsync(pipe, session, connection); + } + }; + + /// + /// Reads until the request head is complete. False means the stream ended first, which is a + /// connection this suite has nothing to say about. + /// + private static async Task ReadHeadAsync(PipeReader input) + { + while (true) + { + ReadResult read = await input.ReadAsync(); + + if (Terminated(read.Buffer)) + { + input.AdvanceTo(read.Buffer.End); + return true; + } + + // Nothing consumed, everything examined: wait for the rest rather than spinning on the + // same partial head. + input.AdvanceTo(read.Buffer.Start, read.Buffer.End); + + if (read.IsCompleted) + { + return false; + } + } + } + + private static bool Terminated(in ReadOnlySequence buffer) + { + var reader = new SequenceReader(buffer); + return reader.TryReadTo(out ReadOnlySequence _, "\r\n\r\n"u8, advancePastDelimiter: true); + } + + /// + /// Teardown shared by the handlers here. Guarded, because one of these tests exists precisely + /// because disposal can throw, and a handler that lets that escape its finally reports as a + /// reactor fault instead of as the test's own result. + /// + private static async Task ReleaseAsync(TlsConnectionDualPipe? pipe, TlsSession? session, TcpConnection connection) + { + try + { + if (pipe is not null) + { + await pipe.DisposeAsync(); + } + else + { + session?.Dispose(); + } + } + catch + { + // Reported through the test's own channel where it matters. + } + + connection.DecRef(); + } + + private static byte[] Pattern(int length) + { + var body = new byte[length]; + for (int i = 0; i < length; i++) + { + body[i] = (byte)('a' + (i % 26)); + } + return body; + } + + /// + /// Reads a whole Content-Length response over TLS, however large, and says whether the body is + /// byte-for-byte what produced. Client.ReadResponse tops out at its 64 KB + /// buffer, which is smaller than the responses these tests are about. + /// + private static (int Status, int Length, bool Intact) GetTlsBody(int port, int timeoutMs = 20_000) + { + using var client = new TcpClient(); + client.Connect("127.0.0.1", port); + client.ReceiveTimeout = timeoutMs; + + using var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false, (_, _, _, _) => true); + ssl.AuthenticateAsClient(new SslClientAuthenticationOptions + { + TargetHost = "localhost", + EnabledSslProtocols = SslProtocols.Tls13, + }); + + ssl.Write(Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\nHost: test\r\n\r\n")); + ssl.Flush(); + + var received = new MemoryStream(); + var buffer = new byte[64 * 1024]; + int headEnd = -1; + + while (headEnd < 0) + { + int n = ssl.Read(buffer, 0, buffer.Length); + if (n <= 0) + { + throw new Exception("the connection closed before the head arrived"); + } + received.Write(buffer, 0, n); + headEnd = received.GetBuffer().AsSpan(0, (int)received.Length).IndexOf("\r\n\r\n"u8); + } + + string head = Encoding.ASCII.GetString(received.GetBuffer(), 0, headEnd); + int status = int.Parse(head.AsSpan(9, 3)); + int contentLength = ContentLength(head); + int bodyStart = headEnd + 4; + + while (received.Length - bodyStart < contentLength) + { + int n = ssl.Read(buffer, 0, buffer.Length); + if (n <= 0) + { + break; // short body: reported as a length mismatch, not as an exception + } + received.Write(buffer, 0, n); + } + + int length = (int)received.Length - bodyStart; + ReadOnlySpan body = received.GetBuffer().AsSpan(bodyStart, Math.Max(length, 0)); + bool intact = length == contentLength && body.SequenceEqual(Pattern(contentLength)); + + return (status, length, intact); + } + + private static int ContentLength(string head) + { + foreach (string line in head.Split("\r\n")) + { + if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) + { + return int.Parse(line.AsSpan("Content-Length:".Length).Trim()); + } + } + return 0; + } + + /// + /// Handshakes, asks for a response, and then never reads a byte of it - the peer that fills a + /// server's send buffer and leaves its flush in flight. Stays connected until the server has + /// reported, because closing would release the send and dissolve the state under test. + /// + private static void RequestAndStopReading(int port, Task settled) + { + using var client = new TcpClient(); + + // A small receive window, set before the connect so it is what gets advertised: the server + // then parks within a megabyte or so instead of after however much this box's autotuning + // decides to buffer. + client.ReceiveBufferSize = 4096; + client.Connect("127.0.0.1", port); + + using var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false, (_, _, _, _) => true); + ssl.AuthenticateAsClient(new SslClientAuthenticationOptions + { + TargetHost = "localhost", + EnabledSslProtocols = SslProtocols.Tls13, + }); + + ssl.Write(Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\nHost: test\r\n\r\n")); + ssl.Flush(); + + settled.Wait(TimeSpan.FromSeconds(60)); } }