From 298cb5d3fc41889dfe69869d7b9cbbb72d1cb593 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 19 Aug 2026 10:43:33 +0100 Subject: [PATCH] tests: land the review round's reproductions, with the refuted claims turned into what is true The 24-agent review round produced 20 test files that were never committed, because 11 of their 31 reproductions had been refuted by an adversarial pass and shipping them would have put two mutually exclusive assertions in the same suite - one of them asserts the opposite of a test that passes today. Leaving all 20 out cost the 77 PASSING tests in them, which is the larger number: controls, matrices and ordinary coverage the agents wrote alongside their claims. So the refuted 11 are converted rather than deleted. Each drove a real scenario that was already wired up; what was wrong was the conclusion. Each now pins the behaviour that was verified correct and records why the claim did not hold: resume a ticket issued before a rotation is REFUSED after it, and the client still served. Ticket keys are per SSL_CTX; nginx, Apache and HAProxy retire them on reload too, and the test below it depends on that - a ticket outliving a rotation would carry the old verify verdict past the new anchors. truncation the pipe reports both endings alike and the SESSION tells them apart. Faulting the cut one would fault every client that merely disposes its SslStream. identity a CN the decoder refuses is not named, even where the rendered subject cannot tell it apart - PeerSubject is documented for people, PeerCommonName is what authorizes. mtls/quic a DN or CN too long to record is reported as NO name, never a prefix. Fails closed on purpose: a prefix can belong to someone else. quic/fault a throwing HandshakeCompleted is logged and the connection keeps serving, which is this runtime's policy for user-code faults everywhere else. http3 a body cut mid-frame kills the connection with H3_FRAME_ERROR and the reader signals an ordinary end - the contract all three body readers document identically. parity omitting the host table is refused on QUIC and applied on TCP, pinned as a KNOWN DIVERGENCE. Neither side is a bug; TCP's is documented and pinned green, so resolving it is a deliberate breaking change rather than a fix, and this makes sure nobody resolves it by accident. lifetime deleted - the file already had a passing test for the only true part. Two were better answered by fixing the code than by rewriting the test, which both the refuting agent and the commit auditor independently recommended: TlsService.Start now range-checks MinProtocolVersion and HandshakeTimeoutMs. Both resolved to something plausible rather than failing - an undefined version through a not-Tls13 ternary to the TLS 1.2 floor, and a negative timeout to no handshake sweep at all, silently removing the only bound on a peer that connects and says nothing. No config binder validates an enum, so neither needed a cast to arrive. Two identity doc comments were corrected to match: null from PeerSubject/PeerCommonName is a refusal, not "the peer offered none". The 19 surviving reproductions stay as Pending - they report PEND and fail the run if they start passing. 419 passed, 0 failed, 20 pending, across all six suites. --- src/ioxide/Tls/TlsService.cs | 23 + .../Connection/QuicEngineConnection.cs | 12 +- .../Ioxide.Tests.E2E/Protocols/H3AlpnTests.cs | 201 +++++- .../Protocols/H3BodyTruncationTests.cs | 499 +++++++++++++- .../Protocols/H3ErrorCodeTests.cs | 614 ++++++++++++++++- .../Protocols/QuicClientCertTimingTests.cs | 93 ++- .../Protocols/QuicDeferredFaultTests.cs | 550 ++++++++++++++- .../Protocols/QuicIdentityCapTests.cs | 39 +- .../Protocols/QuicStreamAllowanceTests.cs | 624 +++++++++++++++++- .../Protocols/QuicTeardownWireTests.cs | 479 +++++++++++++- .../CrossStackParityTests.cs | 95 ++- .../TlsClientErrorQueueTests.cs | 350 +++++++++- .../TlsClientPostureTests.cs | 453 ++++++++++++- .../TlsClientVerificationTests.cs | 595 ++++++++++++++++- .../Ioxide.Tests.Tls/AlpnNegotiationTests.cs | 333 +++++++++- tests/Ioxide.Tests.Tls/AnchorSourceTests.cs | 559 +++++++++++++++- tests/Ioxide.Tests.Tls/ContextBuildTests.cs | 254 ++++++- .../Ioxide.Tests.Tls/IdentitySubjectTests.cs | 430 +++++++++++- tests/Ioxide.Tests.Tls/PrologueReaderTests.cs | 387 ++++++++++- .../Ioxide.Tests.Tls/SessionLifetimeTests.cs | 177 ++++- .../SessionResumptionTests.cs | 504 +++++++++++++- tests/Ioxide.Tests.Tls/TruncationTests.cs | 623 ++++++++++++++++- tests/Ioxide.Tests.Tls/WriterContractTests.cs | 449 ++++++++++++- 23 files changed, 8197 insertions(+), 146 deletions(-) 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)); } }